@opengeni/api-router 0.30.3 → 2.2.0-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 (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -479,6 +479,55 @@ function projectMonitoringContainer(
479
479
  };
480
480
  }
481
481
 
482
+ const SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT = 12;
483
+
484
+ function projectEffectiveToolPolicy(value: Session["effectiveToolPolicy"]): {
485
+ value: unknown;
486
+ fact: SessionDetailFieldFact;
487
+ details: string[];
488
+ } {
489
+ if (!value) return projectMonitoringContainer(null, 16_000);
490
+ const sample = (ids: string[]) => ids.slice(0, SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT);
491
+ const sampled =
492
+ value.selectedIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
493
+ value.effectiveIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
494
+ value.mandatoryIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
495
+ value.lazyRouter.deferredIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
496
+ value.configuredIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
497
+ value.droppedIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT;
498
+ const bounded = {
499
+ ...value,
500
+ selectedIds: sample(value.selectedIds),
501
+ effectiveIds: sample(value.effectiveIds),
502
+ mandatoryIds: sample(value.mandatoryIds),
503
+ lazyRouter: { ...value.lazyRouter, deferredIds: sample(value.lazyRouter.deferredIds) },
504
+ configuredIds: sample(value.configuredIds),
505
+ droppedIds: sample(value.droppedIds),
506
+ idsTruncated: value.idsTruncated || sampled,
507
+ };
508
+ const projected = projectMonitoringContainer(bounded, 16_000);
509
+ const sourceMeasurement = measureSessionEventJson(value);
510
+ return {
511
+ ...projected,
512
+ fact: {
513
+ ...projected.fact,
514
+ truncated:
515
+ sampled ||
516
+ projected.fact.truncated ||
517
+ sourceMeasurement.bytes === null ||
518
+ sourceMeasurement.bytes !== projected.fact.deliveredBytes,
519
+ originalBytes: sourceMeasurement.bytes,
520
+ ...(sourceMeasurement.bytes === null ? { measurementBounded: true } : {}),
521
+ },
522
+ details: sampled
523
+ ? [
524
+ `effectiveToolPolicy: ids sampled at ${SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT} per set; counts remain exact`,
525
+ ...projected.details,
526
+ ]
527
+ : projected.details,
528
+ };
529
+ }
530
+
482
531
  /** Purpose-built, flat, model-facing detail projection for `session_get`. */
483
532
  export function boundSessionDetailMcp(
484
533
  session: Session,
@@ -494,6 +543,7 @@ export function boundSessionDetailMcp(
494
543
  const tools = projectMonitoringContainer(session.tools, 4_000);
495
544
  const mcpServers = projectMonitoringContainer(session.mcpServers, 3_000);
496
545
  const permissions = projectMonitoringContainer(session.firstPartyMcpPermissions, 1_500);
546
+ const effectiveToolPolicy = projectEffectiveToolPolicy(session.effectiveToolPolicy);
497
547
  const control = projectMonitoringContainer(effectiveControl, 2_000);
498
548
  const fieldFacts: Record<string, SessionDetailFieldFact> = {
499
549
  title: title?.fact ?? {
@@ -512,6 +562,7 @@ export function boundSessionDetailMcp(
512
562
  tools: tools.fact,
513
563
  mcpServers: mcpServers.fact,
514
564
  firstPartyMcpPermissions: permissions.fact,
565
+ effectiveToolPolicy: effectiveToolPolicy.fact,
515
566
  effectiveControl: control.fact,
516
567
  };
517
568
  const details = [
@@ -520,6 +571,7 @@ export function boundSessionDetailMcp(
520
571
  ...tools.details,
521
572
  ...mcpServers.details,
522
573
  ...permissions.details,
574
+ ...effectiveToolPolicy.details,
523
575
  ...control.details,
524
576
  ].slice(0, 32);
525
577
  const result = {
@@ -545,6 +597,7 @@ export function boundSessionDetailMcp(
545
597
  rigId: session.rigId,
546
598
  rigVersionId: session.rigVersionId,
547
599
  firstPartyMcpPermissions: permissions.value,
600
+ effectiveToolPolicy: effectiveToolPolicy.value,
548
601
  mcpServers: mcpServers.value,
549
602
  parentSessionId: session.parentSessionId,
550
603
  createIdempotencyKey:
@@ -594,6 +647,7 @@ export function boundSessionDetailMcp(
594
647
  ["mcpServers", fieldFacts.mcpServers!],
595
648
  ["effectiveControl", fieldFacts.effectiveControl!],
596
649
  ["firstPartyMcpPermissions", fieldFacts.firstPartyMcpPermissions!],
650
+ ["effectiveToolPolicy", fieldFacts.effectiveToolPolicy!],
597
651
  ];
598
652
  for (const [field, fact] of fallbackContainers) {
599
653
  if (result.projection.bytes <= maxBytes) break;
@@ -0,0 +1,554 @@
1
+ /**
2
+ * `session_wait`: one blocking first-party MCP call that replaces the
3
+ * `sleep 55` plus three or four `session_events`/`session_get` probes an agent
4
+ * otherwise runs per loop while it waits for a child or peer session.
5
+ *
6
+ * The durable `session_events` table is the only authority. The NATS event bus
7
+ * is live fanout only, so every wake (bus, deadline, or the initial call) is
8
+ * followed by a database read and the returned rows are always the exact
9
+ * durable events, never the bus payload. Subscriptions are opened before the
10
+ * first read so an event committed between the read and the subscription
11
+ * cannot be missed.
12
+ *
13
+ * Timing facts: the built-in `opengeni` MCP server entry carries no
14
+ * `timeoutMs`, so the worker's MCP client uses the SDK default request timeout
15
+ * of 60 s and a longer call would surface as a `-32001` tool error. The wait is
16
+ * therefore capped at {@link SESSION_WAIT_MAX_SECONDS} both in the tool schema
17
+ * and inside {@link waitForSessionChanges}. The API's `Bun.serve`
18
+ * `idleTimeout` is 255 s, far above that cap.
19
+ *
20
+ * Cancellation: the API serves one transport per POST, so the worker's MCP
21
+ * `notifications/cancelled` never reaches this handler; instead the route binds
22
+ * the HTTP request's own abort to `transport.close()` (`request-abort.ts`),
23
+ * which aborts `extra.signal`. A worker that drops the call on Steer/Pause
24
+ * therefore ends the server-side wait promptly; the deadline bounds it anyway.
25
+ *
26
+ * The live bus is best-effort: a failed subscription degrades the wait to the
27
+ * durable pre-check plus the deadline re-check and is reported as
28
+ * `liveFanout: false` rather than failing the tool.
29
+ */
30
+
31
+ import type {
32
+ SessionEvent,
33
+ SessionEventSemanticClass,
34
+ SessionEventType,
35
+ } from "@opengeni/contracts";
36
+ import {
37
+ SESSION_EVENT_SEMANTIC_CLASS_TYPES,
38
+ SESSION_SYSTEM_UPDATE_WAKE_CLASS,
39
+ compactSessionEventResult,
40
+ type SessionSystemUpdateKind,
41
+ } from "@opengeni/contracts";
42
+ import { SESSION_EVENT_MCP_MAX_BYTES, capPayloadValue } from "./session-view";
43
+
44
+ export const SESSION_WAIT_MAX_TARGETS = 16;
45
+ export const SESSION_WAIT_MAX_SECONDS = 50;
46
+ export const SESSION_WAIT_DEFAULT_SECONDS = 45;
47
+ /** Newest-first durable rows read per target on every database check. */
48
+ export const SESSION_WAIT_EVENTS_PER_TARGET = 20;
49
+
50
+ /**
51
+ * The durable event types that matter to a waiter: turn lifecycle, completed
52
+ * agent messages, blocking failures, goal facts, and session status/control
53
+ * changes. Raw deltas, tool receipts, sandbox/machine diagnostics, and PTY
54
+ * noise never wake a waiter; `session_events` remains the drill-down for them.
55
+ */
56
+ export const SESSION_WAIT_EVENT_TYPES = [
57
+ "turn.started",
58
+ "turn.completed",
59
+ "turn.failed",
60
+ "turn.cancelled",
61
+ "turn.superseded",
62
+ "turn.capacity_waiting",
63
+ "agent.message.completed",
64
+ "session.status.changed",
65
+ "session.requiresAction",
66
+ "session.humanInput.requested",
67
+ "session.control.paused",
68
+ "session.control.resumed",
69
+ "tool.auth_needed",
70
+ "credential.auth_needed",
71
+ "rig.setup.failed",
72
+ "goal.set",
73
+ "goal.updated",
74
+ "goal.progress",
75
+ "goal.rewrite.proposed",
76
+ "goal.rewrite.rejected",
77
+ "goal.completed",
78
+ "goal.paused",
79
+ "goal.resumed",
80
+ "goal.cleared",
81
+ "goal.continuation",
82
+ ] as const satisfies readonly SessionEventType[];
83
+
84
+ const SESSION_WAIT_EVENT_TYPE_SET: ReadonlySet<string> = new Set(SESSION_WAIT_EVENT_TYPES);
85
+
86
+ /** The self-session event that announces a newly pending machine input. */
87
+ export const SESSION_WAIT_OWN_PENDING_EVENT_TYPE =
88
+ "system.update.pending" satisfies SessionEventType;
89
+
90
+ /**
91
+ * Whether a pending own machine input of this kind ends the wait. Every
92
+ * pre-existing kind and `child_requires_action` are `immediate`; deferred
93
+ * child notices are reported but do not end the wait by themselves.
94
+ */
95
+ export function ownPendingKindWakes(kind: string): boolean {
96
+ const wakeClass = SESSION_SYSTEM_UPDATE_WAKE_CLASS[kind as SessionSystemUpdateKind];
97
+ return wakeClass === undefined || wakeClass === "immediate";
98
+ }
99
+
100
+ const SEMANTIC_CLASS_PRIORITY: readonly SessionEventSemanticClass[] = [
101
+ "terminal",
102
+ "failure",
103
+ "control",
104
+ "checkpoint",
105
+ "tool_receipt",
106
+ "provider_account",
107
+ ];
108
+
109
+ export function sessionWaitSemanticClass(type: SessionEventType): SessionEventSemanticClass {
110
+ for (const semanticClass of SEMANTIC_CLASS_PRIORITY) {
111
+ const types: readonly SessionEventType[] = SESSION_EVENT_SEMANTIC_CLASS_TYPES[semanticClass];
112
+ if (types.includes(type)) return semanticClass;
113
+ }
114
+ return "control";
115
+ }
116
+
117
+ export type SessionWaitTarget = { sessionId: string; afterSequence: number };
118
+
119
+ export type SessionWaitEventSummary = {
120
+ id: string;
121
+ sequence: number;
122
+ type: SessionEventType;
123
+ occurredAt: string;
124
+ turnId: string | null;
125
+ turnGeneration: number | null;
126
+ status: string;
127
+ text: string | null;
128
+ failure: {
129
+ error: string | null;
130
+ code: string | null;
131
+ retryable: boolean | null;
132
+ recovery: string | null;
133
+ } | null;
134
+ result?: unknown;
135
+ };
136
+
137
+ export type SessionWaitTargetResult = {
138
+ sessionId: string;
139
+ afterSequence: number;
140
+ /** Cursor for the next `session_wait`/`session_events after=` call. */
141
+ latestSequence: number;
142
+ hasMore: boolean;
143
+ events: SessionWaitEventSummary[];
144
+ };
145
+
146
+ export type SessionWaitResult = {
147
+ changed: SessionWaitTargetResult[];
148
+ ownPendingUpdates: number;
149
+ ownPendingUpdateKinds: string[];
150
+ /**
151
+ * Pending own inputs whose kind wakes the session by itself (every
152
+ * pre-existing kind plus `child_requires_action`). Only these end the wait;
153
+ * `deferred` child notices (resolution, pause, capacity wait, progress) are
154
+ * reported but keep the wait on the targets.
155
+ */
156
+ ownPendingImmediateUpdates: number;
157
+ ownPendingDeferredUpdateKinds: string[];
158
+ waitedMs: number;
159
+ timedOut: boolean;
160
+ aborted: boolean;
161
+ /** False when at least one live-fanout subscription failed; the wait then relied on the deadline re-check. */
162
+ liveFanout: boolean;
163
+ truncated: boolean;
164
+ bytes: number;
165
+ maxBytes: number;
166
+ };
167
+
168
+ export type SessionWaitTargetRead = { events: readonly SessionEvent[]; hasMore: boolean };
169
+
170
+ export type SessionWaitSource = {
171
+ /** Durable forward read of matching events strictly after the target cursor. */
172
+ readTargetEvents: (target: SessionWaitTarget) => Promise<SessionWaitTargetRead>;
173
+ /** Kinds of the caller's own pending machine inputs; null disables self tracking. */
174
+ readOwnPendingUpdateKinds: (() => Promise<readonly string[]>) | null;
175
+ /** Live fanout subscription for one session; the returned function unsubscribes. */
176
+ subscribe: (
177
+ sessionId: string,
178
+ onEvents: (events: SessionEvent[]) => void | Promise<void>,
179
+ ) => Promise<() => void>;
180
+ /**
181
+ * Re-run target authorization for the sessions about to be returned after a
182
+ * wait (parity with SSE re-authorization). Throws to refuse the result.
183
+ */
184
+ reauthorizeTargets?: ((sessionIds: readonly string[]) => Promise<void>) | undefined;
185
+ };
186
+
187
+ export type SessionWaitInput = {
188
+ targets: readonly SessionWaitTarget[];
189
+ ownSessionId: string | null;
190
+ maxWaitMs: number;
191
+ source: SessionWaitSource;
192
+ signal?: AbortSignal | undefined;
193
+ now?: (() => number) | undefined;
194
+ maxBytes?: number | undefined;
195
+ };
196
+
197
+ type WakeReason = "bus" | "deadline" | "abort";
198
+
199
+ export async function waitForSessionChanges(input: SessionWaitInput): Promise<SessionWaitResult> {
200
+ const now = input.now ?? Date.now;
201
+ const startedAt = now();
202
+ // The cap is enforced here as well as in the tool schema: no caller can hold
203
+ // the API past the worker's 60 s MCP request timeout.
204
+ const deadlineAt =
205
+ startedAt + Math.min(Math.max(0, input.maxWaitMs), SESSION_WAIT_MAX_SECONDS * 1_000);
206
+ let liveFanout = true;
207
+ let waited = false;
208
+ const ownSessionId = input.source.readOwnPendingUpdateKinds ? input.ownSessionId : null;
209
+
210
+ // One subscription per distinct session; a session may be both a target and
211
+ // the caller's own session, in which case either condition wakes the wait.
212
+ const targetAfter = new Map<string, number>();
213
+ for (const target of input.targets) {
214
+ const existing = targetAfter.get(target.sessionId);
215
+ targetAfter.set(
216
+ target.sessionId,
217
+ existing === undefined ? target.afterSequence : Math.min(existing, target.afterSequence),
218
+ );
219
+ }
220
+ const subscribedSessionIds = new Set<string>(targetAfter.keys());
221
+ if (ownSessionId !== null) subscribedSessionIds.add(ownSessionId);
222
+
223
+ let wake: (() => void) | null = null;
224
+ let wakePending = false;
225
+ const signalWake = () => {
226
+ wakePending = true;
227
+ wake?.();
228
+ };
229
+ const matchesWake = (sessionId: string, events: SessionEvent[]): boolean => {
230
+ const after = targetAfter.get(sessionId);
231
+ for (const event of events) {
232
+ if (event.sessionId !== sessionId) continue;
233
+ if (
234
+ after !== undefined &&
235
+ event.sequence > after &&
236
+ SESSION_WAIT_EVENT_TYPE_SET.has(event.type)
237
+ ) {
238
+ return true;
239
+ }
240
+ if (sessionId === ownSessionId && event.type === SESSION_WAIT_OWN_PENDING_EVENT_TYPE) {
241
+ return true;
242
+ }
243
+ }
244
+ return false;
245
+ };
246
+
247
+ const unsubscribes: Array<() => void> = [];
248
+ const release = () => {
249
+ for (const unsubscribe of unsubscribes.splice(0)) {
250
+ try {
251
+ unsubscribe();
252
+ } catch {
253
+ // Releasing a live-fanout subscription is best-effort; the durable
254
+ // read already decided the result.
255
+ }
256
+ }
257
+ };
258
+
259
+ const readAll = async (): Promise<{
260
+ changed: SessionWaitTargetResult[];
261
+ ownPendingUpdateKinds: string[];
262
+ }> => {
263
+ const [targetReads, ownKinds] = await Promise.all([
264
+ Promise.all(
265
+ input.targets.map(async (target) => ({
266
+ target,
267
+ read: await input.source.readTargetEvents(target),
268
+ })),
269
+ ),
270
+ input.source.readOwnPendingUpdateKinds
271
+ ? input.source.readOwnPendingUpdateKinds()
272
+ : Promise.resolve([] as readonly string[]),
273
+ ]);
274
+ const changed: SessionWaitTargetResult[] = [];
275
+ for (const { target, read } of targetReads) {
276
+ const events = read.events.filter(
277
+ (event) =>
278
+ event.sequence > target.afterSequence && SESSION_WAIT_EVENT_TYPE_SET.has(event.type),
279
+ );
280
+ if (events.length === 0) continue;
281
+ changed.push({
282
+ sessionId: target.sessionId,
283
+ afterSequence: target.afterSequence,
284
+ latestSequence: events[events.length - 1]!.sequence,
285
+ hasMore: read.hasMore,
286
+ events: events.map((event) => summarizeSessionWaitEvent(event)),
287
+ });
288
+ }
289
+ return { changed, ownPendingUpdateKinds: [...ownKinds] };
290
+ };
291
+
292
+ const finish = async (
293
+ read: { changed: SessionWaitTargetResult[]; ownPendingUpdateKinds: string[] },
294
+ outcome: { timedOut: boolean; aborted: boolean },
295
+ ): Promise<SessionWaitResult> => {
296
+ // Authorization ran immediately before the first durable read; a result
297
+ // produced after waiting re-proves every returned target first.
298
+ if (waited && read.changed.length > 0 && input.source.reauthorizeTargets) {
299
+ await input.source.reauthorizeTargets(read.changed.map((target) => target.sessionId));
300
+ }
301
+ const ownKinds = [...new Set(read.ownPendingUpdateKinds)].sort();
302
+ return boundSessionWaitResult(
303
+ {
304
+ changed: read.changed,
305
+ ownPendingUpdates: read.ownPendingUpdateKinds.length,
306
+ ownPendingUpdateKinds: ownKinds,
307
+ ownPendingImmediateUpdates: read.ownPendingUpdateKinds.filter((kind) =>
308
+ ownPendingKindWakes(kind),
309
+ ).length,
310
+ ownPendingDeferredUpdateKinds: ownKinds.filter((kind) => !ownPendingKindWakes(kind)),
311
+ waitedMs: Math.max(0, now() - startedAt),
312
+ timedOut: outcome.timedOut,
313
+ aborted: outcome.aborted,
314
+ liveFanout,
315
+ },
316
+ input.maxBytes,
317
+ );
318
+ };
319
+
320
+ try {
321
+ if (input.signal?.aborted) {
322
+ return await finish(
323
+ { changed: [], ownPendingUpdateKinds: [] },
324
+ { timedOut: false, aborted: true },
325
+ );
326
+ }
327
+ // Subscribe first, then read: an event committed between the read and the
328
+ // subscription would otherwise be missed until the deadline re-check. A
329
+ // failed subscription degrades to pre-check plus deadline re-check.
330
+ for (const sessionId of subscribedSessionIds) {
331
+ try {
332
+ const unsubscribe = await input.source.subscribe(sessionId, (events) => {
333
+ if (matchesWake(sessionId, events)) signalWake();
334
+ });
335
+ unsubscribes.push(unsubscribe);
336
+ } catch {
337
+ liveFanout = false;
338
+ }
339
+ if (input.signal?.aborted) break;
340
+ }
341
+
342
+ for (;;) {
343
+ if (input.signal?.aborted) {
344
+ return await finish(
345
+ { changed: [], ownPendingUpdateKinds: [] },
346
+ { timedOut: false, aborted: true },
347
+ );
348
+ }
349
+ wakePending = false;
350
+ const read = await readAll();
351
+ if (
352
+ read.changed.length > 0 ||
353
+ read.ownPendingUpdateKinds.some((kind) => ownPendingKindWakes(kind))
354
+ ) {
355
+ return await finish(read, { timedOut: false, aborted: false });
356
+ }
357
+ const remainingMs = deadlineAt - now();
358
+ if (remainingMs <= 0) {
359
+ return await finish(read, { timedOut: true, aborted: false });
360
+ }
361
+ if (wakePending) continue;
362
+ const reason = await waitForWake(remainingMs, input.signal, (resolve) => {
363
+ wake = resolve;
364
+ });
365
+ wake = null;
366
+ waited = true;
367
+ if (reason === "abort") {
368
+ return await finish(
369
+ { changed: [], ownPendingUpdateKinds: [] },
370
+ { timedOut: false, aborted: true },
371
+ );
372
+ }
373
+ // "bus" and "deadline" both re-read durable state; the deadline branch is
374
+ // the final re-check that covers any lost live fanout.
375
+ }
376
+ } finally {
377
+ wake = null;
378
+ release();
379
+ }
380
+ }
381
+
382
+ function waitForWake(
383
+ timeoutMs: number,
384
+ signal: AbortSignal | undefined,
385
+ registerWake: (wake: () => void) => void,
386
+ ): Promise<WakeReason> {
387
+ return new Promise<WakeReason>((resolve) => {
388
+ let settled = false;
389
+ let timer: ReturnType<typeof setTimeout> | null = null;
390
+ const onAbort = () => settle("abort");
391
+ const settle = (reason: WakeReason) => {
392
+ if (settled) return;
393
+ settled = true;
394
+ if (timer !== null) clearTimeout(timer);
395
+ signal?.removeEventListener("abort", onAbort);
396
+ resolve(reason);
397
+ };
398
+ registerWake(() => settle("bus"));
399
+ timer = setTimeout(() => settle("deadline"), Math.max(1, Math.ceil(timeoutMs)));
400
+ if (signal) {
401
+ if (signal.aborted) {
402
+ settle("abort");
403
+ return;
404
+ }
405
+ signal.addEventListener("abort", onAbort, { once: true });
406
+ }
407
+ });
408
+ }
409
+
410
+ const SUMMARY_TEXT_TIERS = [2_000, 800, 200] as const;
411
+ const SUMMARY_FAILURE_CHARS = 500;
412
+ const SUMMARY_RESULT_CHARS = 1_000;
413
+
414
+ function truncationMarker(droppedChars: number): string {
415
+ return `…[${droppedChars} chars omitted from this session_wait summary; use session_events for the exact event]`;
416
+ }
417
+
418
+ function clampSummaryString(value: string, maxChars: number): string {
419
+ if (value.length <= maxChars) return value;
420
+ const dropped = value.length - maxChars;
421
+ const headChars = Math.max(0, Math.floor(maxChars * 0.7));
422
+ const tailChars = Math.max(0, maxChars - headChars);
423
+ const tail = tailChars > 0 ? value.slice(value.length - tailChars) : "";
424
+ return `${value.slice(0, headChars)}${truncationMarker(dropped)}${tail}`;
425
+ }
426
+
427
+ /** One bounded, result-bearing summary of a durable event for a waiter. */
428
+ export function summarizeSessionWaitEvent(
429
+ event: SessionEvent,
430
+ textChars: number = SUMMARY_TEXT_TIERS[0],
431
+ ): SessionWaitEventSummary {
432
+ const compact = compactSessionEventResult(event, sessionWaitSemanticClass(event.type));
433
+ const text = compact.text === null ? null : clampSummaryString(compact.text, textChars);
434
+ const failure =
435
+ compact.failure === null
436
+ ? null
437
+ : {
438
+ error:
439
+ compact.failure.error === null
440
+ ? null
441
+ : clampSummaryString(compact.failure.error, SUMMARY_FAILURE_CHARS),
442
+ code:
443
+ compact.failure.code === null ? null : clampSummaryString(compact.failure.code, 128),
444
+ retryable: compact.failure.retryable,
445
+ recovery:
446
+ compact.failure.recovery === null
447
+ ? null
448
+ : clampSummaryString(compact.failure.recovery, SUMMARY_FAILURE_CHARS),
449
+ };
450
+ const summary: SessionWaitEventSummary = {
451
+ id: compact.id,
452
+ sequence: compact.sequence,
453
+ type: compact.type,
454
+ occurredAt: compact.occurredAt,
455
+ turnId: compact.turnId,
456
+ turnGeneration: compact.turnGeneration,
457
+ status: compact.status,
458
+ text,
459
+ failure,
460
+ };
461
+ // `result` repeats `text` for plain message/completion events; only carry a
462
+ // distinct structured result so the summary stays small.
463
+ if (
464
+ compact.result !== null &&
465
+ compact.result !== undefined &&
466
+ compact.result !== compact.text &&
467
+ typeof compact.result !== "string"
468
+ ) {
469
+ summary.result = capPayloadValue(compact.result, SUMMARY_RESULT_CHARS);
470
+ } else if (typeof compact.result === "string" && compact.result !== compact.text) {
471
+ summary.result = clampSummaryString(compact.result, textChars);
472
+ }
473
+ return summary;
474
+ }
475
+
476
+ function prettyJsonBytes(value: unknown): number {
477
+ return Buffer.byteLength(JSON.stringify(value, null, 2), "utf8");
478
+ }
479
+
480
+ function measure(result: SessionWaitResult): SessionWaitResult {
481
+ let measured = result.bytes;
482
+ for (let attempt = 0; attempt < 8; attempt += 1) {
483
+ result.bytes = measured;
484
+ const next = prettyJsonBytes(result);
485
+ if (next === measured) return result;
486
+ measured = next;
487
+ }
488
+ result.bytes = measured;
489
+ return result;
490
+ }
491
+
492
+ /**
493
+ * Keep the whole model-visible result at or below the `session_events` MCP
494
+ * envelope. Text is tightened first; only then are newest rows dropped from the
495
+ * largest target, which keeps each target's `latestSequence` an exact cursor
496
+ * for the rows actually delivered and marks the remainder as `hasMore`.
497
+ */
498
+ export function boundSessionWaitResult(
499
+ input: Omit<SessionWaitResult, "truncated" | "bytes" | "maxBytes">,
500
+ maxBytes: number = SESSION_EVENT_MCP_MAX_BYTES,
501
+ ): SessionWaitResult {
502
+ const envelopeMaxBytes = Math.max(8 * 1024, maxBytes);
503
+ const build = (changed: SessionWaitTargetResult[], truncated: boolean): SessionWaitResult =>
504
+ measure({
505
+ ...input,
506
+ changed,
507
+ truncated,
508
+ bytes: 0,
509
+ maxBytes: envelopeMaxBytes,
510
+ });
511
+
512
+ let candidate = build(input.changed, false);
513
+ if (candidate.bytes <= envelopeMaxBytes) return candidate;
514
+
515
+ // Tier 1: tighten text on every summary. Summaries carry their source id and
516
+ // sequence, so a tighter projection loses no identity.
517
+ const retextured = (textChars: number): SessionWaitTargetResult[] =>
518
+ input.changed.map((target) => ({
519
+ ...target,
520
+ events: target.events.map((event) => ({
521
+ ...event,
522
+ text: event.text === null ? null : clampSummaryString(event.text, textChars),
523
+ ...(typeof event.result === "string"
524
+ ? { result: clampSummaryString(event.result, textChars) }
525
+ : {}),
526
+ })),
527
+ }));
528
+ let changed = input.changed;
529
+ for (const textChars of SUMMARY_TEXT_TIERS.slice(1)) {
530
+ changed = retextured(textChars);
531
+ candidate = build(changed, true);
532
+ if (candidate.bytes <= envelopeMaxBytes) return candidate;
533
+ }
534
+
535
+ // Tier 2: drop newest rows from the largest target until the envelope fits.
536
+ const working = changed.map((target) => ({ ...target, events: [...target.events] }));
537
+ for (;;) {
538
+ let largest: (typeof working)[number] | null = null;
539
+ for (const target of working) {
540
+ if (target.events.length === 0) continue;
541
+ if (largest === null || target.events.length > largest.events.length) largest = target;
542
+ }
543
+ if (largest === null) break;
544
+ largest.events.pop();
545
+ largest.hasMore = true;
546
+ largest.latestSequence =
547
+ largest.events.length > 0
548
+ ? largest.events[largest.events.length - 1]!.sequence
549
+ : largest.afterSequence;
550
+ candidate = build(working, true);
551
+ if (candidate.bytes <= envelopeMaxBytes) return candidate;
552
+ }
553
+ throw new RangeError(`session_wait result exceeds its ${envelopeMaxBytes}-byte envelope`);
554
+ }