letagents 0.12.11 → 0.12.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/mcp/git-remote.js +7 -7
  2. package/dist/mcp/local-state/agent-sessions.js +78 -5
  3. package/dist/mcp/local-state/local-chat.js +189 -48
  4. package/dist/mcp/local-state/storage.js +16 -9
  5. package/dist/mcp/server/daemon-tool-executor.js +92 -0
  6. package/dist/mcp/server/register-tools.js +25 -12
  7. package/dist/mcp/server/runtime/agent-sessions.js +58 -9
  8. package/dist/mcp/server/runtime/api.js +40 -4
  9. package/dist/mcp/server/runtime/daemon-tool-context.js +11 -0
  10. package/dist/mcp/server/runtime/execution-profile.js +19 -0
  11. package/dist/mcp/server/runtime/identity/directory.js +2 -2
  12. package/dist/mcp/server/runtime/messages.js +55 -0
  13. package/dist/mcp/server/runtime/presence.js +4 -2
  14. package/dist/mcp/server/runtime/room-api.js +24 -7
  15. package/dist/mcp/server/runtime/room-state.js +59 -3
  16. package/dist/mcp/server/runtime/rooms.js +67 -32
  17. package/dist/mcp/server/runtime/supervised-room-authority.js +8 -0
  18. package/dist/mcp/server/runtime/supervisor-bridge.js +702 -24
  19. package/dist/mcp/server/runtime/tool-surface-policy.js +26 -0
  20. package/dist/mcp/server/runtime/worker-bearer.js +44 -6
  21. package/dist/mcp/server/runtime-contract.js +27 -0
  22. package/dist/mcp/server/runtime.js +15 -4
  23. package/dist/mcp/server/supervised-tool-facade.js +134 -0
  24. package/dist/mcp/server/tools/agent-sessions.js +74 -7
  25. package/dist/mcp/server/tools/messages/index.js +3 -2
  26. package/dist/mcp/server/tools/messages/read-tool.js +54 -97
  27. package/dist/mcp/server/tools/messages/reasoning-tool.js +2 -0
  28. package/dist/mcp/server/tools/messages/send-tool.js +5 -0
  29. package/dist/mcp/server/tools/messages/status-tool.js +2 -0
  30. package/dist/mcp/server/tools/messages/wait-tool.js +344 -71
  31. package/dist/mcp/server/tools/onboarding/status-tool.js +9 -8
  32. package/dist/mcp/server/tools/rooms/inspection-tools.js +40 -22
  33. package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +2 -1
  34. package/dist/mcp/server/tools/supervised-room-turn.js +42 -0
  35. package/dist/mcp/server/tools/tasks/board-tools.js +34 -2
  36. package/dist/mcp/server.js +14 -7
  37. package/dist/mcp/sse-client.js +163 -20
  38. package/dist/shared/activation-routing.js +187 -20
  39. package/dist/shared/agent-presence.js +6 -0
  40. package/dist/shared/desktop-release-manifest.js +63 -0
  41. package/dist/shared/desktop-release.js +60 -0
  42. package/dist/shared/scoped-ids.js +6 -0
  43. package/package.json +11 -3
  44. package/shared/message-contracts.d.mts +32 -0
  45. package/shared/message-contracts.mjs +109 -0
  46. package/shared/routing-aliases.d.mts +18 -0
  47. package/shared/routing-aliases.mjs +66 -0
  48. package/shared/sqlite-thread-routing.d.mts +72 -0
  49. package/shared/sqlite-thread-routing.mjs +1038 -0
@@ -3,8 +3,10 @@ import { join } from "path";
3
3
  import { z } from "zod";
4
4
  import { getRoomFromConfig } from "../../../config-reader.js";
5
5
  import { buildActiveGitRoomContext, getGitCurrentBranch, getGitDefaultBranch, getGitRoomContext, } from "../../../git-remote.js";
6
- import { currentAgentIdentity, currentAgentIdentityKey, currentRoom, getConversationIdentity, getCurrentLiveSessionPayload, getStoredAgentIdentity, listStoredCodexLiveSessions, toPublicAgentIdentity, toPublicRoomState, withJoinRoomAgentPrompt, } from "../../runtime.js";
6
+ import { currentAgentIdentity, currentAgentIdentityKey, currentRoomMatchesLocator, currentRoom, getConversationIdentity, getCurrentLiveSessionPayload, getStoredAgentIdentity, listStoredCodexLiveSessions, toPublicAgentIdentity, toPublicCurrentRoomState, withJoinRoomAgentPrompt, } from "../../runtime.js";
7
7
  import { requireValidWorkerBearerRuntime } from "../../runtime/worker-bearer.js";
8
+ import { getDaemonToolExecutionContext, getRuntimeWorkingDirectory } from "../../runtime/daemon-tool-context.js";
9
+ import { identityFromAgentSession } from "../../runtime/agent-sessions.js";
8
10
  import { findExistingConfig, resolveGitRoot, } from "../../repo-context.js";
9
11
  import { jsonToolResponse } from "./response.js";
10
12
  let ownerAuthStoreLoader = () => import("../../../local-state.js");
@@ -29,16 +31,18 @@ export function registerRoomInspectionTools(server) {
29
31
  }
30
32
  export async function getCurrentRoomPayload(conversationId) {
31
33
  const runtime = requireValidWorkerBearerRuntime();
32
- const workerAuth = runtime.mode === "worker"
33
- ? { source: "worker_bearer", expires_at: null, account: null }
34
+ const daemonContext = getDaemonToolExecutionContext();
35
+ const publicCurrentRoom = toPublicCurrentRoomState();
36
+ const workerAuth = runtime.mode !== "owner"
37
+ ? { source: runtime.mode === "worker" ? "worker_bearer" : "daemon_supervised", expires_at: null, account: null }
34
38
  : null;
35
- const localCodexDetails = runtime.mode === "worker"
39
+ const localCodexDetails = runtime.mode !== "owner"
36
40
  ? { current_local_codex_session: null, local_codex_session_count: 0 }
37
41
  : {
38
42
  current_local_codex_session: getCurrentLiveSessionPayload(currentRoom?.room_id),
39
43
  local_codex_session_count: listStoredCodexLiveSessions().length,
40
44
  };
41
- if (!currentRoom) {
45
+ if (!publicCurrentRoom) {
42
46
  return {
43
47
  connected: false,
44
48
  message: "Not currently in any room",
@@ -46,16 +50,19 @@ export async function getCurrentRoomPayload(conversationId) {
46
50
  ...(workerAuth ? { auth: workerAuth } : {}),
47
51
  };
48
52
  }
49
- const auth = runtime.mode === "worker"
53
+ const auth = runtime.mode !== "owner"
50
54
  ? null
51
55
  : (await ownerAuthStoreLoader()).getStoredAuth();
52
- return withJoinRoomAgentPrompt({
56
+ const payload = {
53
57
  connected: true,
54
- ...toPublicRoomState(currentRoom),
58
+ ...publicCurrentRoom,
59
+ ...(runtime.mode === "supervised" ? { room_binding: "daemon_supervised" } : {}),
55
60
  ...localCodexDetails,
56
- agent_identity: toPublicAgentIdentity(getConversationIdentity(conversationId)
57
- ?? currentAgentIdentity
58
- ?? getStoredAgentIdentity(currentAgentIdentityKey)),
61
+ agent_identity: toPublicAgentIdentity(daemonContext
62
+ ? identityFromAgentSession(daemonContext.agentSession)
63
+ : getConversationIdentity(conversationId)
64
+ ?? currentAgentIdentity
65
+ ?? getStoredAgentIdentity(currentAgentIdentityKey)),
59
66
  auth: workerAuth ?? (auth
60
67
  ? {
61
68
  source: process.env.LETAGENTS_TOKEN ? "env" : "local_state",
@@ -63,10 +70,15 @@ export async function getCurrentRoomPayload(conversationId) {
63
70
  account: auth.account ?? null,
64
71
  }
65
72
  : null),
66
- });
73
+ };
74
+ // A daemon-supervised worker is already bound to its exact authorized room.
75
+ // A normal join prompt is both false and dangerous here because it can lead
76
+ // the provider to request an unrelated room move.
77
+ return runtime.mode === "supervised" ? payload : withJoinRoomAgentPrompt(payload);
67
78
  }
68
79
  function getRepoInspectionPayload(targetDir) {
69
- const startDir = targetDir || process.cwd();
80
+ const runtime = requireValidWorkerBearerRuntime();
81
+ const startDir = targetDir || getRuntimeWorkingDirectory();
70
82
  const repoRoot = resolveGitRoot(startDir);
71
83
  const configDir = repoRoot ? findExistingConfig(startDir) : null;
72
84
  const configPath = configDir ? join(configDir, ".letagents.json") : null;
@@ -79,8 +91,8 @@ function getRepoInspectionPayload(targetDir) {
79
91
  defaultBranch: repoRoot ? getGitDefaultBranch(repoRoot) : null,
80
92
  })
81
93
  : null;
82
- const detectedRoom = configGitContext?.activeRoom ?? gitContext?.activeRoom ?? null;
83
- const currentRoomMatchesContext = Boolean(currentRoom && detectedRoom && currentRoom.room_id === detectedRoom);
94
+ const detectedRoom = configGitContext?.activeRoomLocator ?? gitContext?.activeRoomLocator ?? null;
95
+ const currentRoomMatchesContext = currentRoomMatchesLocator(detectedRoom);
84
96
  return {
85
97
  cwd: startDir,
86
98
  repo_context_status: repoRoot ? "git_repo_detected" : "not_inside_git_repo",
@@ -88,17 +100,23 @@ function getRepoInspectionPayload(targetDir) {
88
100
  config_file: configPath ?? null,
89
101
  config_contents: readConfigContents(configPath),
90
102
  configured_room_from_file: configuredRoom ?? null,
91
- configured_active_room_from_context: configGitContext?.activeRoom ?? null,
92
- derived_room_from_git: gitContext?.activeRoom ?? null,
103
+ configured_active_room_from_context: configGitContext?.activeRoomLocator ?? null,
104
+ derived_room_from_git: gitContext?.activeRoomLocator ?? null,
93
105
  derived_repo_room_from_git: gitContext?.repoRoom ?? null,
94
- derived_branch_room_from_git: gitContext?.activeRefRoom ?? null,
106
+ derived_branch_room_from_git: gitContext?.activeRefRoomLocator ?? null,
95
107
  git_current_branch: gitContext?.currentBranch ?? configGitContext?.currentBranch ?? null,
96
108
  git_default_branch: gitContext?.defaultBranch ?? configGitContext?.defaultBranch ?? null,
97
109
  detected_room_from_context: detectedRoom ?? null,
98
- current_room: toPublicRoomState(currentRoom),
99
- current_room_scope: currentRoomScope(currentRoomMatchesContext, detectedRoom),
100
- warning: repoWarning({ repoRoot, detectedRoom, currentRoomMatchesContext }),
101
- join_hint: joinHint({ repoRoot, detectedRoom, currentRoomMatchesContext }),
110
+ current_room: toPublicCurrentRoomState(),
111
+ current_room_scope: runtime.mode === "supervised"
112
+ ? "daemon_supervised_exact_room"
113
+ : currentRoomScope(currentRoomMatchesContext, detectedRoom),
114
+ warning: runtime.mode === "supervised"
115
+ ? null
116
+ : repoWarning({ repoRoot, detectedRoom, currentRoomMatchesContext }),
117
+ join_hint: runtime.mode === "supervised"
118
+ ? null
119
+ : joinHint({ repoRoot, detectedRoom, currentRoomMatchesContext }),
102
120
  };
103
121
  }
104
122
  function readConfigContents(configPath) {
@@ -5,6 +5,7 @@ import { getGitRemoteIdentity } from "../../../git-remote.js";
5
5
  import { joinRoomIdentifier, } from "../../runtime.js";
6
6
  import { findExistingConfig, resolveGitRoot, } from "../../repo-context.js";
7
7
  import { jsonToolResponse } from "./response.js";
8
+ import { getRuntimeWorkingDirectory } from "../../runtime/daemon-tool-context.js";
8
9
  export function registerRepoInitializationTool(server) {
9
10
  server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat by creating a .letagents.json config file. " +
10
11
  "This explicitly sets up repo-based room auto-join. Reads git remote to derive the room name, " +
@@ -19,7 +20,7 @@ export function registerRepoInitializationTool(server) {
19
20
  .optional()
20
21
  .describe("Working directory hint for repo detection. Defaults to the current process directory."),
21
22
  }, async ({ room, cwd: targetDir }) => {
22
- const startDir = targetDir || process.cwd();
23
+ const startDir = targetDir || getRuntimeWorkingDirectory();
23
24
  const repoRoot = resolveGitRoot(startDir);
24
25
  if (!repoRoot) {
25
26
  return jsonToolResponse({
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ const MAX_ROOM_REPLY_BYTES = 32 * 1024;
3
+ function completionResponse(outcome) {
4
+ const payload = {
5
+ accepted: true,
6
+ outcome,
7
+ instruction: "The daemon recorded this exact turn completion. End the provider turn without sending the activating reply through another tool.",
8
+ };
9
+ return {
10
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
11
+ structuredContent: payload,
12
+ };
13
+ }
14
+ /**
15
+ * Cursor's documented terminal `result` is the concatenation of every
16
+ * assistant delta, including progress narration. A supervised provider must
17
+ * therefore propose its public reply over a distinct, turn-scoped channel.
18
+ *
19
+ * This tool is registered only for `supervised_room_turn`. The normal
20
+ * supervised tool facade binds it to the exact provider turn and commits its
21
+ * request/result through the durable bounded-effect journal before the daemon
22
+ * will consider it publishable.
23
+ */
24
+ export function registerSupervisedRoomTurnTools(server) {
25
+ server.tool("complete_room_turn", "Record the single public completion for this supervised room turn. Call exactly once after all work is finished. Progress text outside this tool is live activity only and is never published as the room reply.", {
26
+ outcome: z.enum(["reply", "no_reply"]).describe("Use reply to publish one answer, or no_reply when the activating message needs no response."),
27
+ text: z.string().optional().describe("The concise public room reply. Required only when outcome is reply."),
28
+ }, async ({ outcome, text }) => {
29
+ const normalized = text?.trim() ?? "";
30
+ if (outcome === "reply") {
31
+ if (!normalized)
32
+ throw new Error("complete_room_turn requires non-empty text when outcome is reply.");
33
+ if (Buffer.byteLength(normalized, "utf8") > MAX_ROOM_REPLY_BYTES) {
34
+ throw new Error("complete_room_turn reply exceeds the 32 KiB public-message limit.");
35
+ }
36
+ }
37
+ else if (normalized) {
38
+ throw new Error("complete_room_turn must omit text when outcome is no_reply.");
39
+ }
40
+ return completionResponse(outcome);
41
+ });
42
+ }
@@ -4,6 +4,34 @@ import { createTask, listTasks } from "./api.js";
4
4
  import { resolveTaskToolIdentity, resolveTaskToolTarget, taskActorPayload, } from "./context.js";
5
5
  import { jsonToolResponse, taskToolError } from "./response.js";
6
6
  import { boardIntentApprovalSchema, TASK_STATUSES, workerTaskIdentitySchema } from "./schemas.js";
7
+ export const MAX_BOARD_WORKFLOW_ARTIFACTS_PER_TASK = 4;
8
+ export const MAX_BOARD_WORKFLOW_REFS_PER_TASK = 4;
9
+ function compactBoardWorkflowArtifact(value) {
10
+ if (!value || typeof value !== "object" || Array.isArray(value))
11
+ return value;
12
+ const artifact = value;
13
+ // A change-summary detail may itself contain hundreds of files. The board is
14
+ // an index; callers that need complete artifact detail use get_room_artifacts.
15
+ return Object.fromEntries(Object.entries(artifact).filter(([key]) => key !== "detail"));
16
+ }
17
+ export function compactTaskForBoard(value) {
18
+ if (!value || typeof value !== "object" || Array.isArray(value))
19
+ return value;
20
+ const task = value;
21
+ const artifacts = Array.isArray(task.workflow_artifacts) ? task.workflow_artifacts : [];
22
+ const refs = Array.isArray(task.workflow_refs) ? task.workflow_refs : [];
23
+ return {
24
+ ...task,
25
+ workflow_artifacts: artifacts
26
+ .slice(-MAX_BOARD_WORKFLOW_ARTIFACTS_PER_TASK)
27
+ .map(compactBoardWorkflowArtifact),
28
+ workflow_refs: refs.slice(-MAX_BOARD_WORKFLOW_REFS_PER_TASK),
29
+ workflow_artifact_count: artifacts.length,
30
+ workflow_ref_count: refs.length,
31
+ workflow_artifacts_truncated: artifacts.length > MAX_BOARD_WORKFLOW_ARTIFACTS_PER_TASK,
32
+ workflow_refs_truncated: refs.length > MAX_BOARD_WORKFLOW_REFS_PER_TASK,
33
+ };
34
+ }
7
35
  export function registerTaskBoardTools(server) {
8
36
  server.tool("add_task", "Add a new task to the room board. Tasks normally start as 'proposed' and must be " +
9
37
  "accepted before an agent can claim them. Agent-created tasks require coordinator " +
@@ -59,7 +87,7 @@ export function registerTaskBoardTools(server) {
59
87
  const qs = pageParams.toString();
60
88
  const result = await listTasks(target, qs);
61
89
  const tasks = result.tasks ?? [];
62
- allTasks.push(...tasks);
90
+ allTasks.push(...tasks.map(compactTaskForBoard));
63
91
  if (!result.has_more || tasks.length === 0)
64
92
  break;
65
93
  const lastTask = tasks[tasks.length - 1];
@@ -68,6 +96,10 @@ export function registerTaskBoardTools(server) {
68
96
  afterCursor = lastTask.id;
69
97
  }
70
98
  await heartbeatRoomPresence(target.effectiveRoomId, await ensureAgentIdentity());
71
- return jsonToolResponse({ success: true, tasks: allTasks }, 2);
99
+ return jsonToolResponse({
100
+ success: true,
101
+ tasks: allTasks,
102
+ artifact_detail_instruction: "Board tasks contain bounded artifact summaries. Use get_room_artifacts for complete workflow artifact detail.",
103
+ }, 2);
72
104
  });
73
105
  }
@@ -5,14 +5,21 @@ import { registerRoomResources } from "./server/resources.js";
5
5
  import { registerTools } from "./server/register-tools.js";
6
6
  import { attachMcpServer, autoJoinFromContext, shutdownRuntime } from "./server/runtime.js";
7
7
  import { requireValidWorkerBearerRuntime } from "./server/runtime/worker-bearer.js";
8
- const server = new McpServer({
9
- name: "letagents",
10
- version: "0.2.0",
11
- });
12
- attachMcpServer(server);
13
- registerRoomResources(server);
14
- registerTools(server);
8
+ import { executionProfile } from "./server/runtime/execution-profile.js";
9
+ import { LETAGENTS_RUNTIME_CONTRACT_ARG, letAgentsRuntimeContract, } from "./server/runtime-contract.js";
15
10
  async function main() {
11
+ if (process.argv.slice(2).includes(LETAGENTS_RUNTIME_CONTRACT_ARG)) {
12
+ process.stdout.write(`${JSON.stringify(letAgentsRuntimeContract())}\n`);
13
+ return;
14
+ }
15
+ const server = new McpServer({
16
+ name: "letagents",
17
+ version: "0.2.0",
18
+ });
19
+ attachMcpServer(server);
20
+ registerRoomResources(server);
21
+ const profile = executionProfile();
22
+ registerTools(server, profile);
16
23
  requireValidWorkerBearerRuntime();
17
24
  const transport = new StdioServerTransport();
18
25
  await server.connect(transport);
@@ -1,19 +1,33 @@
1
1
  import { encodeRoomIdPath } from "./room-id.js";
2
+ const MAX_SSE_FRAME_BYTES = 1024 * 1024;
3
+ class SseFrameOverflowError extends Error {
4
+ constructor() {
5
+ super("SSE frame exceeded bounded size");
6
+ this.name = "SseFrameOverflowError";
7
+ }
8
+ }
9
+ class SseFrameMalformedError extends Error {
10
+ constructor(options) {
11
+ super("SSE frame was malformed", options);
12
+ this.name = "SseFrameMalformedError";
13
+ }
14
+ }
2
15
  export class SseClient {
3
16
  apiUrl;
4
17
  getAccessToken;
5
18
  subscriptions = new Map();
19
+ eventCursors = new Map();
6
20
  constructor(apiUrl, getAccessToken) {
7
21
  this.apiUrl = apiUrl.replace(/\/$/, "");
8
22
  this.getAccessToken = getAccessToken;
9
23
  }
10
- subscribe(target, onMessage) {
24
+ subscribe(target, onMessage, onGap) {
11
25
  const subscriptionKey = target.roomId;
12
26
  if (this.subscriptions.has(subscriptionKey)) {
13
27
  return;
14
28
  }
15
29
  const controller = new AbortController();
16
- const promise = this.consumeStream(target, controller.signal, onMessage)
30
+ const promise = this.consumeStream(target, controller.signal, onMessage, onGap)
17
31
  .catch((error) => {
18
32
  if (controller.signal.aborted) {
19
33
  return;
@@ -51,17 +65,41 @@ export class SseClient {
51
65
  Authorization: `Bearer ${token}`,
52
66
  };
53
67
  }
54
- async consumeStream(target, signal, onMessage) {
55
- try {
56
- await this.openStream(this.withIncludePromptOnly(this.withAgentIdentityQuery(`${this.apiUrl}/rooms/${encodeRoomIdPath(target.roomId)}/messages/stream`, target)), signal, onMessage);
57
- return;
58
- }
59
- catch (error) {
60
- if (!target.projectId || !this.isMissingRouteError(error)) {
61
- throw error;
68
+ async consumeStream(target, signal, onMessage, onGap) {
69
+ let lastEventId = this.eventCursors.get(target.roomId) ?? null;
70
+ let useLegacyRoute = false;
71
+ let retryMs = 1_000;
72
+ while (!signal.aborted) {
73
+ const baseUrl = useLegacyRoute && target.projectId
74
+ ? `${this.apiUrl}/projects/${encodeURIComponent(target.projectId)}/messages/stream`
75
+ : `${this.apiUrl}/rooms/${encodeRoomIdPath(target.roomId)}/messages/stream`;
76
+ const url = this.withIncludePromptOnly(this.withAgentIdentityQuery(baseUrl, target));
77
+ try {
78
+ lastEventId = await this.openStream(url, signal, target.roomId, lastEventId, onMessage, onGap);
79
+ retryMs = 1_000;
62
80
  }
81
+ catch (error) {
82
+ if (signal.aborted)
83
+ return;
84
+ if (error instanceof SseFrameOverflowError || error instanceof SseFrameMalformedError) {
85
+ lastEventId = null;
86
+ }
87
+ if (this.isMissingRouteError(error)) {
88
+ if (!useLegacyRoute && target.projectId) {
89
+ useLegacyRoute = true;
90
+ continue;
91
+ }
92
+ // A missing room/route is terminal. Retrying it forever creates
93
+ // background traffic and can never heal without a new room join.
94
+ throw error;
95
+ }
96
+ console.error(`SSE stream disconnected for room ${target.roomId}:`, error);
97
+ }
98
+ if (signal.aborted)
99
+ return;
100
+ await waitForRetry(signal, retryMs);
101
+ retryMs = Math.min(Math.ceil(retryMs * 1.5), 30_000);
63
102
  }
64
- await this.openStream(this.withIncludePromptOnly(this.withAgentIdentityQuery(`${this.apiUrl}/projects/${encodeURIComponent(target.projectId)}/messages/stream`, target)), signal, onMessage);
65
103
  }
66
104
  withIncludePromptOnly(url) {
67
105
  return `${url}${url.includes("?") ? "&" : "?"}include_prompt_only=1`;
@@ -80,9 +118,14 @@ export class SseClient {
80
118
  }
81
119
  return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`;
82
120
  }
83
- async openStream(url, signal, onMessage) {
121
+ async openStream(url, signal, roomId, lastEventId, onMessage, onGap) {
122
+ const headers = await this.getHeaders();
123
+ if (signal.aborted)
124
+ return lastEventId;
125
+ if (lastEventId)
126
+ headers["Last-Event-ID"] = lastEventId;
84
127
  const response = await fetch(url, {
85
- headers: await this.getHeaders(),
128
+ headers,
86
129
  signal,
87
130
  });
88
131
  if (!response.ok) {
@@ -94,43 +137,143 @@ export class SseClient {
94
137
  const reader = response.body.getReader();
95
138
  const decoder = new TextDecoder();
96
139
  let buffer = "";
140
+ let bufferBytes = 0;
97
141
  while (!signal.aborted) {
98
142
  const { value, done } = await reader.read();
99
143
  if (done) {
100
144
  break;
101
145
  }
102
146
  buffer += decoder.decode(value, { stream: true });
147
+ bufferBytes += value.byteLength;
148
+ if (bufferBytes > MAX_SSE_FRAME_BYTES) {
149
+ // The cursor is no longer safe: an unterminated/oversize frame may
150
+ // contain an event we could not parse. Force pull-state repair before
151
+ // reconnecting and never retain attacker-controlled remainder bytes.
152
+ this.eventCursors.delete(roomId);
153
+ try {
154
+ onGap?.({ room_id: roomId, event_cursor: null, gap: true });
155
+ }
156
+ catch (error) {
157
+ console.error("SSE gap callback failed:", error);
158
+ }
159
+ await reader.cancel("SSE frame exceeded bounded size").catch(() => undefined);
160
+ throw new SseFrameOverflowError();
161
+ }
103
162
  let boundaryIndex = buffer.indexOf("\n\n");
163
+ let consumedFrame = false;
104
164
  while (boundaryIndex !== -1) {
105
165
  const rawEvent = buffer.slice(0, boundaryIndex);
106
166
  buffer = buffer.slice(boundaryIndex + 2);
107
- this.handleEvent(rawEvent, onMessage);
167
+ try {
168
+ lastEventId = this.handleEvent(rawEvent, lastEventId, onMessage, onGap);
169
+ }
170
+ catch (error) {
171
+ // A complete but malformed typed frame is the same lost-boundary
172
+ // condition as an oversized frame. Never reconnect from its cursor
173
+ // and replay it forever; force the authorized pull-state repair.
174
+ this.eventCursors.delete(roomId);
175
+ try {
176
+ onGap?.({ room_id: roomId, event_cursor: null, gap: true });
177
+ }
178
+ catch (gapError) {
179
+ console.error("SSE gap callback failed:", gapError);
180
+ }
181
+ await reader.cancel("SSE frame was malformed").catch(() => undefined);
182
+ throw new SseFrameMalformedError({ cause: error });
183
+ }
184
+ this.rememberEventCursor(roomId, lastEventId);
185
+ consumedFrame = true;
108
186
  boundaryIndex = buffer.indexOf("\n\n");
109
187
  }
188
+ if (consumedFrame)
189
+ bufferBytes = new TextEncoder().encode(buffer).byteLength;
110
190
  }
111
191
  const trailing = buffer + decoder.decode();
112
192
  if (trailing.trim()) {
113
- this.handleEvent(trailing, onMessage);
193
+ try {
194
+ lastEventId = this.handleEvent(trailing, lastEventId, onMessage, onGap);
195
+ }
196
+ catch (error) {
197
+ this.eventCursors.delete(roomId);
198
+ try {
199
+ onGap?.({ room_id: roomId, event_cursor: null, gap: true });
200
+ }
201
+ catch (gapError) {
202
+ console.error("SSE gap callback failed:", gapError);
203
+ }
204
+ throw new SseFrameMalformedError({ cause: error });
205
+ }
206
+ this.rememberEventCursor(roomId, lastEventId);
114
207
  }
208
+ return lastEventId;
115
209
  }
116
- handleEvent(rawEvent, onMessage) {
210
+ handleEvent(rawEvent, lastEventId, onMessage, onGap) {
117
211
  const normalizedEvent = rawEvent.replace(/\r/g, "");
118
- const dataLines = normalizedEvent
119
- .split("\n")
212
+ const lines = normalizedEvent.split("\n");
213
+ const eventName = lines.find((line) => line.startsWith("event:"))?.slice(6).trim() || "message";
214
+ const frameId = lines.find((line) => line.startsWith("id:"))?.slice(3).trim() || null;
215
+ const dataLines = lines
120
216
  .filter((line) => line.startsWith("data:"))
121
217
  .map((line) => line.slice(5).trimStart());
122
218
  if (dataLines.length === 0) {
123
- return;
219
+ return frameId ?? lastEventId;
124
220
  }
125
221
  // No prompt enrichment here: the only SSE subscriber (room-state.ts) discards
126
222
  // the message body, and expanding the room-agent prompt on this background
127
223
  // path would consume the once-per-session full-prompt delivery before the
128
224
  // agent ever sees it. Prompt expansion happens only on visible tool
129
225
  // responses (runtime/messages.ts).
130
- onMessage(JSON.parse(dataLines.join("\n")));
226
+ const payload = JSON.parse(dataLines.join("\n"));
227
+ if (eventName === "room_sync") {
228
+ if (payload.gap === true) {
229
+ try {
230
+ onGap?.(payload);
231
+ }
232
+ catch (error) {
233
+ console.error("SSE gap callback failed:", error);
234
+ }
235
+ }
236
+ if (Object.prototype.hasOwnProperty.call(payload, "event_cursor")) {
237
+ return typeof payload.event_cursor === "string" ? payload.event_cursor : null;
238
+ }
239
+ return frameId ?? lastEventId;
240
+ }
241
+ try {
242
+ onMessage(payload);
243
+ }
244
+ catch (error) {
245
+ console.error("SSE message callback failed:", error);
246
+ }
247
+ return frameId ?? lastEventId;
131
248
  }
132
249
  isMissingRouteError(error) {
133
250
  return (error instanceof Error &&
134
251
  /status 404|status 405|Cannot (GET|POST|PATCH)/.test(error.message));
135
252
  }
253
+ rememberEventCursor(roomId, cursor) {
254
+ this.eventCursors.delete(roomId);
255
+ if (!cursor)
256
+ return;
257
+ this.eventCursors.set(roomId, cursor);
258
+ while (this.eventCursors.size > 32) {
259
+ const oldestRoomId = this.eventCursors.keys().next().value;
260
+ if (!oldestRoomId)
261
+ break;
262
+ this.eventCursors.delete(oldestRoomId);
263
+ }
264
+ }
265
+ }
266
+ function waitForRetry(signal, delayMs) {
267
+ if (signal.aborted)
268
+ return Promise.resolve();
269
+ return new Promise((resolve) => {
270
+ const timer = setTimeout(finish, delayMs);
271
+ timer.unref?.();
272
+ function finish() {
273
+ signal.removeEventListener("abort", finish);
274
+ clearTimeout(timer);
275
+ resolve();
276
+ }
277
+ signal.addEventListener("abort", finish, { once: true });
278
+ });
136
279
  }