@opengeni/api-router 2.5.0 → 2.6.4

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 (59) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  3. package/dist/auth/managed-auth.d.ts +24 -1
  4. package/dist/{chunk-QESX7HDK.js → chunk-XTLI3CBH.js} +2236 -387
  5. package/dist/chunk-XTLI3CBH.js.map +1 -0
  6. package/dist/http/sse.d.ts +9 -0
  7. package/dist/index.js +104 -4
  8. package/dist/index.js.map +1 -1
  9. package/dist/interaction-metrics.d.ts +2 -0
  10. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  11. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  12. package/dist/mcp/remember.d.ts +2 -2
  13. package/dist/mcp/server.d.ts +1 -0
  14. package/dist/mcp/session-view.d.ts +1 -0
  15. package/dist/mcp/session-wait.d.ts +19 -0
  16. package/dist/routes/api-keys.d.ts +2 -0
  17. package/dist/routes/browser-sessions.d.ts +1 -0
  18. package/dist/routes/computer-sessions.d.ts +12 -0
  19. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  20. package/dist/routes/workspaces.d.ts +1 -1
  21. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  22. package/dist/workspace-delete-observability.d.ts +10 -0
  23. package/package.json +15 -15
  24. package/src/app.ts +172 -20
  25. package/src/auth/managed-auth-attempt-context.ts +40 -3
  26. package/src/auth/managed-auth-session-adapter.ts +1 -0
  27. package/src/auth/managed-auth.ts +164 -4
  28. package/src/http/sse.ts +279 -45
  29. package/src/integrations/oauth-client.ts +8 -1
  30. package/src/integrations/provider-oauth.ts +12 -2
  31. package/src/interaction-metrics.ts +30 -0
  32. package/src/mcp/company-brain-governed-writes.ts +38 -23
  33. package/src/mcp/company-profile-agent-admin.ts +7 -7
  34. package/src/mcp/remember.ts +19 -8
  35. package/src/mcp/server.ts +318 -26
  36. package/src/mcp/session-wait.ts +56 -8
  37. package/src/routes/api-integrations.ts +2 -2
  38. package/src/routes/api-keys.ts +149 -7
  39. package/src/routes/browser-sessions.ts +10 -2
  40. package/src/routes/capabilities.ts +3 -3
  41. package/src/routes/codex.ts +483 -74
  42. package/src/routes/company-profile.ts +64 -0
  43. package/src/routes/computer-sessions.ts +103 -1
  44. package/src/routes/integration-facets.ts +8 -5
  45. package/src/routes/interaction-resources.ts +7 -1
  46. package/src/routes/managed-auth-session-sets.ts +199 -2
  47. package/src/routes/organization-memberships.ts +28 -8
  48. package/src/routes/packs.ts +5 -5
  49. package/src/routes/plugins.ts +2 -2
  50. package/src/routes/scheduled-tasks.ts +9 -0
  51. package/src/routes/sessions.ts +3 -6
  52. package/src/routes/skills.ts +3 -3
  53. package/src/routes/workspaces.ts +293 -54
  54. package/src/sandbox/channel-a.ts +10 -4
  55. package/src/sandbox/machines.ts +13 -6
  56. package/src/sandbox/metrics-ingestion.ts +157 -3
  57. package/src/sandbox/viewer.ts +20 -1
  58. package/src/workspace-delete-observability.ts +75 -0
  59. 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,14 @@ import {
141
149
  authorizedSocialConnectionsForGrant,
142
150
  authorizedAtlassianConnectionsForGrant,
143
151
  buildCapabilityCatalog,
152
+ correctWorkspaceMemoryWithSlackPublication,
144
153
  nativeConnectionCapabilityRecommendations,
145
154
  requireLiveAgentAttemptAuthorization,
146
155
  requireSessionAuthorization,
147
156
  requireSessionAuthorizationListScope,
148
157
  SessionAuthorizationDeniedError,
149
158
  SessionAuthorizationUnavailableError,
159
+ saveWorkspaceMemoryWithSlackPublication,
150
160
  searchCapabilityCatalogItems,
151
161
  type ResolvedSessionAuthorization,
152
162
  } from "@opengeni/core";
@@ -191,6 +201,7 @@ import {
191
201
  ScheduledTaskSyncError,
192
202
  syncCreatedScheduledTask,
193
203
  syncUpdatedScheduledTask,
204
+ validateScheduledTaskMachineTarget,
194
205
  validateScheduledTaskTarget,
195
206
  updateScheduledTaskForApi,
196
207
  validatedScheduledTaskUpdate,
@@ -228,11 +239,13 @@ import {
228
239
  SESSION_EVENT_MCP_MAX_BYTES,
229
240
  } from "./session-view";
230
241
  import {
242
+ SESSION_WAIT_COMPLETION_EVENT_TYPES,
231
243
  SESSION_WAIT_DEFAULT_SECONDS,
232
244
  SESSION_WAIT_EVENT_TYPES,
233
245
  SESSION_WAIT_EVENTS_PER_TARGET,
234
246
  SESSION_WAIT_MAX_SECONDS,
235
247
  SESSION_WAIT_MAX_TARGETS,
248
+ sessionWaitCompletionEventMatches,
236
249
  waitForSessionChanges,
237
250
  } from "./session-wait";
238
251
  import {
@@ -275,6 +288,12 @@ export type McpServerOptions = {
275
288
 
276
289
  const ORCHESTRATION_FAILURE_CODE_MAX_LENGTH = 128;
277
290
  const ORCHESTRATION_FAILURE_MESSAGE_MAX_UTF8_BYTES = 1_024;
291
+ // Keep pathological raw MCP payloads away from Unicode normalization while
292
+ // leaving the shared DB normalizer authoritative for the exact post-NFKC
293
+ // code-point limit. The multiplier admits supplementary-plane characters,
294
+ // decomposed forms, and ordinary whitespace folding without reopening the
295
+ // API-wide request-body ceiling for this 200-code-point field.
296
+ const MCP_DISCOVERY_QUERY_MAX_UTF16_CODE_UNITS = WORK_DISCOVERY_QUERY_MAX_CHARS * 8;
278
297
 
279
298
  type OrchestrationToolName = "session_create" | "session_send_message";
280
299
 
@@ -409,9 +428,6 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
409
428
  goal_complete: { sessionRequired: true, allOf: ["goals:manage"] },
410
429
  goal_pause: { sessionRequired: true, allOf: ["goals:manage"] },
411
430
  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
431
  memory_save: { sessionRequired: true, allOf: ["documents:search"] },
416
432
  memory_correct: { sessionRequired: true, allOf: ["documents:search"] },
417
433
  preference_registry_summary: {
@@ -1714,6 +1730,14 @@ export function buildOpenGeniMcpServer(
1714
1730
  agentConfig: task.agentConfig,
1715
1731
  missingTargetStatus: 404,
1716
1732
  });
1733
+ await validateScheduledTaskMachineTarget({
1734
+ settings: deps.settings,
1735
+ db: deps.db,
1736
+ grant,
1737
+ runMode: task.runMode,
1738
+ agentConfig: task.agentConfig,
1739
+ requireOnline: true,
1740
+ });
1717
1741
  await requireLimit(deps, {
1718
1742
  accountId: grant.accountId,
1719
1743
  workspaceId: grant.workspaceId,
@@ -3425,6 +3449,7 @@ function registerPreferenceRegistryTools(
3425
3449
  }
3426
3450
 
3427
3451
  const MemoryKindSchema = z4.enum(["preference", "semantic", "procedural", "decision", "episodic"]);
3452
+ const MemoryWriteKindSchema = z4.enum(["semantic", "decision", "episodic"]);
3428
3453
 
3429
3454
  function scheduledTaskReceipt(
3430
3455
  operation: string,
@@ -3516,6 +3541,11 @@ export function memorySlackPublicationActor(
3516
3541
  };
3517
3542
  }
3518
3543
 
3544
+ function memoryPreview(text: string): string {
3545
+ const normalized = text.replace(/\s+/g, " ").trim();
3546
+ return normalized.length <= 120 ? normalized : `${normalized.slice(0, 119)}…`;
3547
+ }
3548
+
3519
3549
  function registerMemoryTools(
3520
3550
  server: McpServer,
3521
3551
  deps: ApiRouteDeps,
@@ -3524,10 +3554,17 @@ function registerMemoryTools(
3524
3554
  json: JsonResult,
3525
3555
  promptMode: WorkspaceMemoryPromptMode,
3526
3556
  ): void {
3557
+ const publicationInputSchema = z4.object({
3558
+ importance: z4.enum(["major", "normal", "minor"]),
3559
+ audience: z4.literal("workspace"),
3560
+ slackMode: z4.enum(["auto", "review", "never"]),
3561
+ shareSummary: z4.string().trim().min(1).max(4_096),
3562
+ });
3563
+
3527
3564
  server.registerTool(
3528
3565
  "memory_search",
3529
3566
  {
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.`,
3567
+ 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
3568
  inputSchema: {
3532
3569
  query: z4.string().min(1),
3533
3570
  kind: MemoryKindSchema.optional(),
@@ -3550,10 +3587,239 @@ function registerMemoryTools(
3550
3587
  }),
3551
3588
  );
3552
3589
 
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.
3590
+ // Memory writes are agent-only. Human creation and curation use the REST/UI
3591
+ // surface; a non-attempt MCP principal may search but cannot mutate Memory.
3592
+ if (exactAgentAttemptClaims(grant) === null) return;
3593
+
3594
+ server.registerTool(
3595
+ "memory_save",
3596
+ {
3597
+ description: MEMORY_SAVE_TOOL_DESCRIPTION,
3598
+ inputSchema: {
3599
+ text: z4.string().min(1),
3600
+ kind: MemoryWriteKindSchema,
3601
+ confidence: z4.number().min(0).max(1).optional(),
3602
+ replaces_id: z4.string().min(1).optional(),
3603
+ slack_publication: publicationInputSchema.optional(),
3604
+ },
3605
+ },
3606
+ async ({ text, kind, confidence, replaces_id, slack_publication }) => {
3607
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
3608
+ const result = await saveWorkspaceMemoryWithSlackPublication(
3609
+ deps.db,
3610
+ {
3611
+ accountId: grant.accountId,
3612
+ workspaceId: grant.workspaceId,
3613
+ sessionId,
3614
+ text,
3615
+ kind,
3616
+ ...(confidence !== undefined ? { confidence } : {}),
3617
+ ...(replaces_id ? { replacesId: replaces_id } : {}),
3618
+ origin: "agent",
3619
+ },
3620
+ slack_publication
3621
+ ? {
3622
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
3623
+ actor: memorySlackPublicationActor(actor, sessionId, grant.subjectLabel ?? null)
3624
+ .actor,
3625
+ ownerLabel: actor.initiator.label ?? grant.subjectLabel ?? null,
3626
+ }
3627
+ : null,
3628
+ deps.getDocumentServices().embedder,
3629
+ );
3630
+ let timelineWarning: string | null = null;
3631
+ try {
3632
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
3633
+ {
3634
+ type: "memory.saved",
3635
+ payload: {
3636
+ memoryId: result.memory.id,
3637
+ kind: result.memory.kind,
3638
+ preview: memoryPreview(result.memory.text),
3639
+ deduped: result.deduped,
3640
+ ...(result.superseded ? { supersededMemoryId: result.superseded.id } : {}),
3641
+ },
3642
+ },
3643
+ ]);
3644
+ } catch {
3645
+ timelineWarning = "Memory committed, but its session timeline event could not be recorded.";
3646
+ console.warn("workspace memory save: committed without session timeline event", {
3647
+ errorClass: "MemoryTimelineOperationError",
3648
+ errorCode: "memory_save_timeline_append_failed",
3649
+ origin: "api",
3650
+ workspaceId: grant.workspaceId,
3651
+ sessionId,
3652
+ memoryId: result.memory.id,
3653
+ });
3654
+ }
3655
+ const changed = !result.deduped || result.updated || result.superseded !== null;
3656
+ const outcome =
3657
+ result.updated || result.superseded !== null
3658
+ ? "updated"
3659
+ : result.deduped
3660
+ ? "unchanged"
3661
+ : "created";
3662
+ return json(
3663
+ mcpMutationReceipt({
3664
+ operation: "memory_save",
3665
+ committed: true,
3666
+ outcome,
3667
+ changed,
3668
+ resource: {
3669
+ type: "knowledge_memory",
3670
+ id: result.memory.id,
3671
+ state: result.memory.status,
3672
+ },
3673
+ relatedResources: result.superseded
3674
+ ? [
3675
+ {
3676
+ type: "knowledge_memory",
3677
+ id: result.superseded.id,
3678
+ state: result.superseded.status,
3679
+ },
3680
+ ]
3681
+ : undefined,
3682
+ timestamp: result.memory.updatedAt,
3683
+ idempotency: { status: "not_supported" },
3684
+ warnings: [
3685
+ ...(!result.embedded
3686
+ ? ["Memory committed without a vector embedding; keyword search remains available."]
3687
+ : []),
3688
+ ...(timelineWarning ? [timelineWarning] : []),
3689
+ ],
3690
+ facts: {
3691
+ deduped: result.deduped,
3692
+ dedupeReason: result.dedupeReason,
3693
+ updatedInPlace: result.updated,
3694
+ embedded: result.embedded,
3695
+ slackPublicationDecision: result.slackPublication.decision?.eligible
3696
+ ? "eligible"
3697
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
3698
+ slackPublicationId:
3699
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3700
+ result.slackPublication.enqueue?.kind === "replayed"
3701
+ ? result.slackPublication.enqueue.publication.id
3702
+ : null,
3703
+ slackPublicationState:
3704
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3705
+ result.slackPublication.enqueue?.kind === "replayed"
3706
+ ? result.slackPublication.enqueue.publication.state
3707
+ : null,
3708
+ },
3709
+ }),
3710
+ );
3711
+ },
3712
+ );
3713
+
3714
+ server.registerTool(
3715
+ "memory_correct",
3716
+ {
3717
+ description: MEMORY_CORRECT_TOOL_DESCRIPTION,
3718
+ inputSchema: {
3719
+ id: z4.string().min(1),
3720
+ reason: z4.string().min(1).optional(),
3721
+ replacement_text: z4.string().min(1).optional(),
3722
+ slack_publication: publicationInputSchema.optional(),
3723
+ },
3724
+ },
3725
+ async ({ id, reason, replacement_text, slack_publication }) => {
3726
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
3727
+ const result = await correctWorkspaceMemoryWithSlackPublication(
3728
+ deps.db,
3729
+ {
3730
+ accountId: grant.accountId,
3731
+ workspaceId: grant.workspaceId,
3732
+ sessionId,
3733
+ id,
3734
+ ...(reason ? { reason } : {}),
3735
+ ...(replacement_text ? { replacementText: replacement_text } : {}),
3736
+ origin: "agent",
3737
+ },
3738
+ slack_publication
3739
+ ? {
3740
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
3741
+ actor: memorySlackPublicationActor(actor, sessionId, grant.subjectLabel ?? null)
3742
+ .actor,
3743
+ ownerLabel: actor.initiator.label ?? grant.subjectLabel ?? null,
3744
+ }
3745
+ : null,
3746
+ deps.getDocumentServices().embedder,
3747
+ );
3748
+ let timelineWarning: string | null = null;
3749
+ try {
3750
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
3751
+ {
3752
+ type: "memory.corrected",
3753
+ payload: {
3754
+ memoryId: result.memory.id,
3755
+ kind: result.memory.kind,
3756
+ preview: memoryPreview(result.memory.text),
3757
+ action: result.action,
3758
+ ...(reason ? { reason: memoryPreview(reason) } : {}),
3759
+ ...(result.replacement
3760
+ ? {
3761
+ replacementMemoryId: result.replacement.id,
3762
+ replacementPreview: memoryPreview(result.replacement.text),
3763
+ }
3764
+ : {}),
3765
+ },
3766
+ },
3767
+ ]);
3768
+ } catch {
3769
+ timelineWarning =
3770
+ "Memory correction committed, but its session timeline event could not be recorded.";
3771
+ console.warn("workspace memory correction: committed without session timeline event", {
3772
+ errorClass: "MemoryTimelineOperationError",
3773
+ errorCode: "memory_correct_timeline_append_failed",
3774
+ origin: "api",
3775
+ workspaceId: grant.workspaceId,
3776
+ sessionId,
3777
+ memoryId: result.memory.id,
3778
+ });
3779
+ }
3780
+ return json(
3781
+ mcpMutationReceipt({
3782
+ operation: "memory_correct",
3783
+ committed: true,
3784
+ outcome: "updated",
3785
+ changed: true,
3786
+ resource: {
3787
+ type: "knowledge_memory",
3788
+ id: result.memory.id,
3789
+ state: result.memory.status,
3790
+ },
3791
+ relatedResources: result.replacement
3792
+ ? [
3793
+ {
3794
+ type: "knowledge_memory",
3795
+ id: result.replacement.id,
3796
+ state: result.replacement.status,
3797
+ },
3798
+ ]
3799
+ : undefined,
3800
+ timestamp: (result.replacement ?? result.memory).updatedAt,
3801
+ idempotency: { status: "not_supported" },
3802
+ warnings: timelineWarning ? [timelineWarning] : [],
3803
+ facts: {
3804
+ correctionAction: result.action,
3805
+ slackPublicationDecision: result.slackPublication.decision?.eligible
3806
+ ? "eligible"
3807
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
3808
+ slackPublicationId:
3809
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3810
+ result.slackPublication.enqueue?.kind === "replayed"
3811
+ ? result.slackPublication.enqueue.publication.id
3812
+ : null,
3813
+ slackPublicationState:
3814
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3815
+ result.slackPublication.enqueue?.kind === "replayed"
3816
+ ? result.slackPublication.enqueue.publication.state
3817
+ : null,
3818
+ },
3819
+ }),
3820
+ );
3821
+ },
3822
+ );
3557
3823
  }
3558
3824
 
3559
3825
  // Fleet tools (M7 bring-your-own-compute). Session-scoped (they steer THIS
@@ -4155,7 +4421,7 @@ function registerWorkspaceOrchestrationTools(
4155
4421
  includeLastMessage: z4.boolean().optional(),
4156
4422
  orderBy: z4.enum(["createdAt", "updatedAt", "relevance"]).optional(),
4157
4423
  updatedAfter: z4.string().max(64).optional(),
4158
- query: z4.string().max(WORK_DISCOVERY_QUERY_MAX_CHARS).optional(),
4424
+ query: z4.string().max(MCP_DISCOVERY_QUERY_MAX_UTF16_CODE_UNITS).optional(),
4159
4425
  statuses: z4
4160
4426
  .array(
4161
4427
  z4.enum([
@@ -4443,7 +4709,7 @@ function registerWorkspaceOrchestrationTools(
4443
4709
  server.registerTool(
4444
4710
  "session_wait",
4445
4711
  {
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.`,
4712
+ 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
4713
  inputSchema: {
4448
4714
  targets: z4
4449
4715
  .array(
@@ -4460,10 +4726,16 @@ function registerWorkspaceOrchestrationTools(
4460
4726
  .describe(
4461
4727
  "Also return when your own session has pending machine input (default true).",
4462
4728
  ),
4729
+ waitFor: z4
4730
+ .enum(["change", "completion"])
4731
+ .optional()
4732
+ .describe(
4733
+ "change (default) returns on relevant activity; completion ignores messages, goal/progress, maintenance, and continuation segments until a result-bearing final turn or blocker.",
4734
+ ),
4463
4735
  maxWaitSeconds: z4.number().int().min(1).max(SESSION_WAIT_MAX_SECONDS).optional(),
4464
4736
  },
4465
4737
  },
4466
- async ({ targets, includeOwnPendingUpdates, maxWaitSeconds }, extra) => {
4738
+ async ({ targets, includeOwnPendingUpdates, waitFor, maxWaitSeconds }, extra) => {
4467
4739
  const distinct = new Set(targets.map((target) => target.sessionId));
4468
4740
  if (distinct.size !== targets.length) {
4469
4741
  throw new Error("session_wait targets must name distinct sessions");
@@ -4475,6 +4747,8 @@ function registerWorkspaceOrchestrationTools(
4475
4747
  await requireSession(deps.db, grant.workspaceId, target.sessionId);
4476
4748
  }
4477
4749
  const ownSessionId = includeOwnPendingUpdates === false ? null : callerSessionId;
4750
+ const targetEventTypes =
4751
+ waitFor === "completion" ? SESSION_WAIT_COMPLETION_EVENT_TYPES : SESSION_WAIT_EVENT_TYPES;
4478
4752
  // The API serves one transport per POST, so the worker's MCP cancel
4479
4753
  // notification never reaches this handler; the route binds the HTTP
4480
4754
  // request's abort to transport.close() (mcp/request-abort.ts), which
@@ -4491,6 +4765,9 @@ function registerWorkspaceOrchestrationTools(
4491
4765
  targets,
4492
4766
  ownSessionId,
4493
4767
  maxWaitMs: (maxWaitSeconds ?? SESSION_WAIT_DEFAULT_SECONDS) * 1_000,
4768
+ targetEventTypes,
4769
+ targetEventMatches:
4770
+ waitFor === "completion" ? sessionWaitCompletionEventMatches : undefined,
4494
4771
  signal,
4495
4772
  source: {
4496
4773
  reauthorizeTargets: async (sessionIds) => {
@@ -4509,7 +4786,7 @@ function registerWorkspaceOrchestrationTools(
4509
4786
  direction: "after",
4510
4787
  limit: SESSION_WAIT_EVENTS_PER_TARGET,
4511
4788
  payloadMode: "full",
4512
- includeTypes: SESSION_WAIT_EVENT_TYPES,
4789
+ includeTypes: targetEventTypes,
4513
4790
  maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
4514
4791
  });
4515
4792
  return { events: page.events, hasMore: page.hasMore };
@@ -4538,6 +4815,14 @@ function registerWorkspaceOrchestrationTools(
4538
4815
  const sessionCreateInput = z4
4539
4816
  .object({
4540
4817
  initialMessage: z4.string().min(1),
4818
+ title: z4
4819
+ .string()
4820
+ .min(1)
4821
+ .max(SESSION_TITLE_MAX_CHARACTERS)
4822
+ .optional()
4823
+ .describe(
4824
+ "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.",
4825
+ ),
4541
4826
  instructions: z4.string().min(1).max(SESSION_INSTRUCTIONS_MAX_CHARACTERS).optional(),
4542
4827
  goal: z4.unknown().optional(),
4543
4828
  resources: z4.array(z4.unknown()).optional(),
@@ -4620,7 +4905,7 @@ function registerWorkspaceOrchestrationTools(
4620
4905
  "session_create",
4621
4906
  {
4622
4907
  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.",
4908
+ "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
4909
  inputSchema: sessionCreateInput,
4625
4910
  },
4626
4911
  async (args) => {
@@ -4633,18 +4918,25 @@ function registerWorkspaceOrchestrationTools(
4633
4918
  if (callerSessionId !== null) {
4634
4919
  await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
4635
4920
  }
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
- });
4921
+ const { machineTarget, title, ...request } = args;
4922
+ const result = await createSessionForRequestWithOutcome(
4923
+ deps,
4924
+ grant,
4925
+ grant.workspaceId,
4926
+ {
4927
+ ...request,
4928
+ ...(machineTarget
4929
+ ? {
4930
+ targetSandboxId: machineTarget.targetSandboxId,
4931
+ ...(machineTarget.workingDir !== undefined
4932
+ ? { workingDir: machineTarget.workingDir }
4933
+ : {}),
4934
+ }
4935
+ : {}),
4936
+ },
4937
+ undefined,
4938
+ title === undefined ? {} : { automaticTitleCandidate: title },
4939
+ );
4648
4940
  return json(sessionCreateMutationReceipt(result, Boolean(request.idempotencyKey)));
4649
4941
  } catch (error) {
4650
4942
  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({
@@ -148,7 +148,7 @@ export function registerApiIntegrationRoutes(
148
148
 
149
149
  app.post("/v1/workspaces/:workspaceId/integrations/install", async (c) => {
150
150
  const workspaceId = c.req.param("workspaceId");
151
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
151
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
152
152
  const payload = InstallApiIntegrationRequest.parse(await c.req.json());
153
153
  const resolved = await resolveForRoute({
154
154
  deps,
@@ -261,7 +261,7 @@ export function registerApiIntegrationRoutes(
261
261
  "/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey",
262
262
  async (c) => {
263
263
  const workspaceId = c.req.param("workspaceId");
264
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
264
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
265
265
  const capabilityId = decodeURIComponent(c.req.param("capabilityId"));
266
266
  const instanceKey = decodeURIComponent(c.req.param("instanceKey"));
267
267
  const payload = UninstallApiIntegrationRequest.parse(await c.req.json());