whale-code 6.4.0 → 6.5.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 (187) hide show
  1. package/bin/swagmanager-mcp.js +7 -0
  2. package/dist/cli/app.js +30 -2
  3. package/dist/cli/chat/ChatApp.d.ts +4 -4
  4. package/dist/cli/chat/ChatApp.js +114 -44
  5. package/dist/cli/chat/ChatInput.d.ts +13 -6
  6. package/dist/cli/chat/ChatInput.js +433 -89
  7. package/dist/cli/chat/MemoryManager.d.ts +15 -0
  8. package/dist/cli/chat/MemoryManager.js +61 -0
  9. package/dist/cli/chat/MessageList.d.ts +8 -0
  10. package/dist/cli/chat/MessageList.js +1 -1
  11. package/dist/cli/chat/NodeManager.d.ts +30 -0
  12. package/dist/cli/chat/NodeManager.js +89 -0
  13. package/dist/cli/chat/NodeSelector.d.ts +19 -0
  14. package/dist/cli/chat/NodeSelector.js +37 -0
  15. package/dist/cli/chat/PlanApproval.d.ts +17 -0
  16. package/dist/cli/chat/PlanApproval.js +82 -0
  17. package/dist/cli/chat/SessionManager.d.ts +16 -0
  18. package/dist/cli/chat/SessionManager.js +43 -0
  19. package/dist/cli/chat/SlashMenu.d.ts +38 -0
  20. package/dist/cli/chat/SlashMenu.js +208 -0
  21. package/dist/cli/chat/StatusBar.d.ts +16 -0
  22. package/dist/cli/chat/StatusBar.js +22 -0
  23. package/dist/cli/chat/ThemeSelector.d.ts +14 -0
  24. package/dist/cli/chat/ThemeSelector.js +29 -0
  25. package/dist/cli/chat/ToolIndicator.d.ts +8 -0
  26. package/dist/cli/chat/ToolIndicator.js +33 -9
  27. package/dist/cli/chat/hooks/useAgentLoop.d.ts +2 -1
  28. package/dist/cli/chat/hooks/useAgentLoop.js +22 -17
  29. package/dist/cli/chat/hooks/useSlashCommands.d.ts +19 -0
  30. package/dist/cli/chat/hooks/useSlashCommands.js +254 -15
  31. package/dist/cli/commands/config-cmd.js +4 -25
  32. package/dist/cli/commands/db.d.ts +13 -0
  33. package/dist/cli/commands/db.js +243 -0
  34. package/dist/cli/commands/doctor.js +6 -9
  35. package/dist/cli/commands/mcp.js +1 -20
  36. package/dist/cli/services/agent-events.d.ts +22 -1
  37. package/dist/cli/services/agent-events.js +9 -0
  38. package/dist/cli/services/agent-loop.js +66 -2
  39. package/dist/cli/services/agent-worker-base.js +21 -6
  40. package/dist/cli/services/api-retry.d.ts +25 -0
  41. package/dist/cli/services/api-retry.js +91 -0
  42. package/dist/cli/services/auth-service.d.ts +1 -1
  43. package/dist/cli/services/auth-service.js +40 -19
  44. package/dist/cli/services/background-processes.js +26 -2
  45. package/dist/cli/services/config-store.d.ts +13 -1
  46. package/dist/cli/services/config-store.js +116 -13
  47. package/dist/cli/services/format-server-response.js +12 -6
  48. package/dist/cli/services/ink-resize-fix.d.ts +18 -0
  49. package/dist/cli/services/ink-resize-fix.js +66 -0
  50. package/dist/cli/services/interactive-tools.d.ts +14 -0
  51. package/dist/cli/services/interactive-tools.js +47 -2
  52. package/dist/cli/services/keybinding-manager.js +1 -1
  53. package/dist/cli/services/local-tools.js +35 -2
  54. package/dist/cli/services/server-tools.js +175 -3
  55. package/dist/cli/services/subagent.js +15 -3
  56. package/dist/cli/services/system-prompt.js +5 -3
  57. package/dist/cli/services/task-decomposer.d.ts +35 -0
  58. package/dist/cli/services/task-decomposer.js +199 -0
  59. package/dist/cli/services/team-lead.d.ts +18 -0
  60. package/dist/cli/services/team-lead.js +80 -0
  61. package/dist/cli/services/teammate.js +5 -5
  62. package/dist/cli/services/telemetry.d.ts +8 -2
  63. package/dist/cli/services/telemetry.js +116 -92
  64. package/dist/cli/services/tools/agent-tools.d.ts +1 -0
  65. package/dist/cli/services/tools/agent-tools.js +50 -4
  66. package/dist/cli/services/tools/file-ops.d.ts +2 -0
  67. package/dist/cli/services/tools/file-ops.js +71 -19
  68. package/dist/cli/services/tools/shell-exec.js +22 -12
  69. package/dist/cli/shared/Theme.d.ts +1 -2
  70. package/dist/cli/shared/Theme.js +1 -1
  71. package/dist/cli/shared/WhaleBanner.d.ts +4 -1
  72. package/dist/cli/shared/WhaleBanner.js +12 -8
  73. package/dist/cli/shared/markdown.d.ts +5 -4
  74. package/dist/cli/shared/markdown.js +376 -334
  75. package/dist/cli/shared/theme-manager.d.ts +27 -0
  76. package/dist/cli/shared/theme-manager.js +178 -0
  77. package/dist/cli/shared/theme-presets.d.ts +16 -0
  78. package/dist/cli/shared/theme-presets.js +265 -0
  79. package/dist/index.js +0 -51
  80. package/dist/node/adapters/imessage.d.ts +10 -0
  81. package/dist/node/adapters/imessage.js +45 -6
  82. package/dist/node/cli.js +459 -8
  83. package/dist/node/config.d.ts +17 -0
  84. package/dist/node/gateway-client.d.ts +55 -0
  85. package/dist/node/gateway-client.js +201 -0
  86. package/dist/node/portal/clipboard.d.ts +28 -0
  87. package/dist/node/portal/clipboard.js +183 -0
  88. package/dist/node/portal/discovery.d.ts +29 -0
  89. package/dist/node/portal/discovery.js +61 -0
  90. package/dist/node/portal/forward.d.ts +30 -0
  91. package/dist/node/portal/forward.js +90 -0
  92. package/dist/node/portal/index.d.ts +47 -0
  93. package/dist/node/portal/index.js +250 -0
  94. package/dist/node/portal/multiplexer.d.ts +48 -0
  95. package/dist/node/portal/multiplexer.js +207 -0
  96. package/dist/node/portal/permissions.d.ts +36 -0
  97. package/dist/node/portal/permissions.js +131 -0
  98. package/dist/node/portal/protocol.d.ts +140 -0
  99. package/dist/node/portal/protocol.js +193 -0
  100. package/dist/node/portal/screen.d.ts +18 -0
  101. package/dist/node/portal/screen.js +93 -0
  102. package/dist/node/portal/session.d.ts +68 -0
  103. package/dist/node/portal/session.js +127 -0
  104. package/dist/node/portal/shell.d.ts +26 -0
  105. package/dist/node/portal/shell.js +142 -0
  106. package/dist/node/portal/stream.d.ts +43 -0
  107. package/dist/node/portal/stream.js +90 -0
  108. package/dist/node/portal/transfer.d.ts +33 -0
  109. package/dist/node/portal/transfer.js +231 -0
  110. package/dist/node/portal/ui.d.ts +16 -0
  111. package/dist/node/portal/ui.js +148 -0
  112. package/dist/node/remote-desktop/compile-helper.d.ts +13 -0
  113. package/dist/node/remote-desktop/compile-helper.js +73 -0
  114. package/dist/node/remote-desktop/index.d.ts +67 -0
  115. package/dist/node/remote-desktop/index.js +220 -0
  116. package/dist/node/remote-desktop/protocol.d.ts +96 -0
  117. package/dist/node/remote-desktop/protocol.js +67 -0
  118. package/dist/node/runtime.d.ts +8 -1
  119. package/dist/node/runtime.js +117 -9
  120. package/dist/server/handlers/__test-utils__/test-db.d.ts +25 -0
  121. package/dist/server/handlers/__test-utils__/test-db.js +128 -0
  122. package/dist/server/handlers/api-keys.js +26 -2
  123. package/dist/server/handlers/browser.d.ts +0 -4
  124. package/dist/server/handlers/browser.js +0 -46
  125. package/dist/server/handlers/catalog.js +37 -14
  126. package/dist/server/handlers/clickhouse.d.ts +10 -0
  127. package/dist/server/handlers/clickhouse.js +215 -0
  128. package/dist/server/handlers/comms.d.ts +308 -4
  129. package/dist/server/handlers/comms.js +444 -11
  130. package/dist/server/handlers/creations.js +1 -1
  131. package/dist/server/handlers/crm.d.ts +54 -8
  132. package/dist/server/handlers/crm.js +353 -68
  133. package/dist/server/handlers/embeddings.js +3 -3
  134. package/dist/server/handlers/enrichment.js +39 -55
  135. package/dist/server/handlers/inventory.js +1 -1
  136. package/dist/server/handlers/kali.d.ts +9 -1
  137. package/dist/server/handlers/kali.js +50 -1
  138. package/dist/server/handlers/media.d.ts +8 -0
  139. package/dist/server/handlers/media.js +902 -0
  140. package/dist/server/handlers/meta-ads.js +6 -3
  141. package/dist/server/handlers/nodes.d.ts +2 -0
  142. package/dist/server/handlers/nodes.js +331 -40
  143. package/dist/server/handlers/operations.d.ts +4 -6
  144. package/dist/server/handlers/operations.js +99 -38
  145. package/dist/server/handlers/platform.js +224 -107
  146. package/dist/server/handlers/remove-bg.d.ts +6 -0
  147. package/dist/server/handlers/remove-bg.js +96 -0
  148. package/dist/server/handlers/storefront.d.ts +6 -0
  149. package/dist/server/handlers/storefront.js +477 -0
  150. package/dist/server/handlers/supply-chain.js +21 -3
  151. package/dist/server/handlers/workflow-steps.js +87 -31
  152. package/dist/server/handlers/workflows.js +4 -1
  153. package/dist/server/index.js +334 -88
  154. package/dist/server/lib/clickhouse-buffer.d.ts +48 -0
  155. package/dist/server/lib/clickhouse-buffer.js +175 -0
  156. package/dist/server/lib/clickhouse-client.d.ts +112 -0
  157. package/dist/server/lib/clickhouse-client.js +141 -0
  158. package/dist/server/lib/coa-renderer.d.ts +91 -0
  159. package/dist/server/lib/coa-renderer.js +411 -0
  160. package/dist/server/lib/compaction-service.js +45 -1
  161. package/dist/server/lib/pdf-renderer.d.ts +143 -0
  162. package/dist/server/lib/pdf-renderer.js +867 -0
  163. package/dist/server/lib/react-pdf-layout.d.ts +40 -0
  164. package/dist/server/lib/react-pdf-layout.js +437 -0
  165. package/dist/server/lib/server-agent-loop.d.ts +2 -0
  166. package/dist/server/lib/server-agent-loop.js +61 -15
  167. package/dist/server/lib/server-subagent.d.ts +3 -0
  168. package/dist/server/lib/server-subagent.js +7 -4
  169. package/dist/server/lib/supabase-client.js +51 -3
  170. package/dist/server/lib/template-resolver.js +14 -4
  171. package/dist/server/lib/utils.js +15 -0
  172. package/dist/server/local-agent-gateway.d.ts +44 -0
  173. package/dist/server/local-agent-gateway.js +389 -49
  174. package/dist/server/providers/anthropic.js +12 -2
  175. package/dist/server/providers/gemini.js +17 -2
  176. package/dist/server/proxy-handlers.js +151 -0
  177. package/dist/server/tool-router.d.ts +2 -2
  178. package/dist/server/tool-router.js +25 -35
  179. package/dist/shared/agent-core.d.ts +5 -2
  180. package/dist/shared/agent-core.js +30 -4
  181. package/dist/shared/api-client.js +54 -3
  182. package/dist/shared/sse-parser.d.ts +1 -1
  183. package/dist/shared/sse-parser.js +5 -2
  184. package/dist/shared/tool-dispatch.js +1 -1
  185. package/package.json +16 -10
  186. package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +0 -11
  187. package/dist/server/handlers/__test-utils__/mock-supabase.js +0 -393
@@ -13,6 +13,7 @@
13
13
  import { WebSocketServer, WebSocket } from "ws";
14
14
  import { randomUUID, createHash } from "node:crypto";
15
15
  import { createClient } from "@supabase/supabase-js";
16
+ import { queueSpan, auditRowToSpan } from "./lib/clickhouse-buffer.js";
16
17
  // ============================================================================
17
18
  // CONNECTION POOL
18
19
  // ============================================================================
@@ -20,7 +21,12 @@ import { createClient } from "@supabase/supabase-js";
20
21
  const agentPool = new Map();
21
22
  /** Map<requestId, pending request> */
22
23
  const pendingRequests = new Map();
23
- const MAX_AGENTS_PER_STORE = 5;
24
+ /** Map<sessionId, RemoteDesktopSession> — active remote desktop relay sessions */
25
+ const rdSessions = new Map();
26
+ /** Map<sessionId, PortalRelaySession> — active portal sessions */
27
+ const portalSessions = new Map();
28
+ const PORTAL_MARKER = 0x01;
29
+ const MAX_AGENTS_PER_STORE = 25;
24
30
  const HEARTBEAT_INTERVAL_MS = 30_000;
25
31
  const PONG_TIMEOUT_MS = 45_000;
26
32
  const MAX_OUTPUT_CHARS = 500 * 1024; // 500KB safety cap — context_management handles limits
@@ -72,6 +78,7 @@ export function initLocalAgentGateway(server) {
72
78
  function handleConnection(ws, _req) {
73
79
  let authenticated = false;
74
80
  let agent = null;
81
+ let viewerSession = null;
75
82
  // Auth timeout — must authenticate within 10 seconds
76
83
  const authTimer = setTimeout(() => {
77
84
  if (!authenticated) {
@@ -80,6 +87,45 @@ function handleConnection(ws, _req) {
80
87
  }
81
88
  }, 10_000);
82
89
  ws.on("message", async (raw) => {
90
+ // Handle binary messages — forward to paired viewer/agent in RD/portal sessions
91
+ if (typeof raw !== "string" && !isJsonString(raw)) {
92
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
93
+ // Portal binary frames (marker 0x01) — route by session UUID
94
+ if (buf.length > 17 && buf[0] === PORTAL_MARKER) {
95
+ const sessionUuid = buf.subarray(1, 17).toString("hex");
96
+ const sessionId = `${sessionUuid.slice(0, 8)}-${sessionUuid.slice(8, 12)}-${sessionUuid.slice(12, 16)}-${sessionUuid.slice(16, 20)}-${sessionUuid.slice(20)}`;
97
+ const portalSession = portalSessions.get(sessionId);
98
+ if (portalSession && agent) {
99
+ // Forward to the other side
100
+ const targetAgentId = agent.agentId === portalSession.initiatorAgentId
101
+ ? portalSession.targetAgentId
102
+ : portalSession.initiatorAgentId;
103
+ const target = pickAgentById(targetAgentId);
104
+ if (target?.ws.readyState === WebSocket.OPEN) {
105
+ target.ws.send(buf);
106
+ portalSession.bytesRelayed += buf.length;
107
+ }
108
+ }
109
+ return;
110
+ }
111
+ // Remote desktop binary frames (no marker or 0x00) — existing behavior
112
+ if (agent) {
113
+ for (const [, session] of rdSessions) {
114
+ if (session.agentId === agent.agentId && session.viewerWs.readyState === WebSocket.OPEN) {
115
+ session.viewerWs.send(buf);
116
+ session.framesRelayed++;
117
+ session.bytesRelayed += buf.length;
118
+ }
119
+ }
120
+ }
121
+ if (viewerSession) {
122
+ const target = pickAgentById(viewerSession.agentId);
123
+ if (target?.ws.readyState === WebSocket.OPEN) {
124
+ target.ws.send(buf);
125
+ }
126
+ }
127
+ return;
128
+ }
83
129
  let msg;
84
130
  try {
85
131
  msg = JSON.parse(raw.toString());
@@ -88,6 +134,54 @@ function handleConnection(ws, _req) {
88
134
  send(ws, { type: "error", error: "Invalid JSON" });
89
135
  return;
90
136
  }
137
+ // Handle viewer auth (remote desktop relay)
138
+ if (msg.type === "auth" && msg.role === "viewer") {
139
+ clearTimeout(authTimer);
140
+ const viewerAuth = msg;
141
+ const authResult = await authenticateViewer(viewerAuth);
142
+ if (!authResult) {
143
+ send(ws, { type: "error", error: "Invalid token or unauthorized" });
144
+ ws.close(4003, "Authentication failed");
145
+ return;
146
+ }
147
+ authenticated = true;
148
+ // Find target agent with remote_desktop capability
149
+ const targetAgent = pickAgent(authResult.storeId, undefined, viewerAuth.target_node_id);
150
+ if (!targetAgent || !targetAgent.capabilities.includes("remote_desktop")) {
151
+ send(ws, { type: "remote_desktop_response", success: false, error: "No remote desktop capable node online" });
152
+ ws.close(4004, "No target node");
153
+ return;
154
+ }
155
+ // Create relay session
156
+ const sessionId = randomUUID();
157
+ viewerSession = {
158
+ sessionId,
159
+ viewerWs: ws,
160
+ agentId: targetAgent.agentId,
161
+ storeId: authResult.storeId,
162
+ userId: authResult.userId,
163
+ startedAt: new Date(),
164
+ framesRelayed: 0,
165
+ bytesRelayed: 0,
166
+ };
167
+ rdSessions.set(sessionId, viewerSession);
168
+ // Tell the agent to start a remote desktop session
169
+ send(targetAgent.ws, {
170
+ type: "remote_desktop",
171
+ request_id: randomUUID(),
172
+ type_inner: "remote_desktop_request",
173
+ session_id: sessionId,
174
+ viewer_user_id: authResult.userId,
175
+ });
176
+ send(ws, {
177
+ type: "remote_desktop_response",
178
+ success: true,
179
+ session_id: sessionId,
180
+ agent_hostname: targetAgent.hostname,
181
+ });
182
+ console.log(`[remote-desktop] Relay session ${sessionId}: viewer=${authResult.userId} → agent=${targetAgent.agentId}`);
183
+ return;
184
+ }
91
185
  // Handle auth
92
186
  if (msg.type === "auth") {
93
187
  clearTimeout(authTimer);
@@ -101,6 +195,7 @@ function handleConnection(ws, _req) {
101
195
  ws,
102
196
  storeId: authResult.storeId.toLowerCase(),
103
197
  userId: authResult.userId,
198
+ nodeId: authResult.nodeId,
104
199
  capabilities: msg.capabilities || [],
105
200
  connectedAt: new Date(),
106
201
  lastPong: Date.now(),
@@ -125,25 +220,34 @@ function handleConnection(ws, _req) {
125
220
  message: `Connected to SwagManager. ${agent.capabilities.length} local tools registered.`,
126
221
  });
127
222
  console.log(`[local-agent] Agent connected: store=${agent.storeId} platform=${agent.platform} hostname=${agent.hostname} tools=${agent.capabilities.length}`);
128
- // Audit log
129
- if (supabaseClient) {
130
- supabaseClient.from("audit_logs").insert({
131
- action: "local_agent.connected",
132
- severity: "info",
133
- store_id: agent.storeId,
134
- resource_type: "local_agent",
135
- resource_id: agent.agentId,
136
- source: "local_agent",
137
- details: {
138
- platform: agent.platform,
139
- hostname: agent.hostname,
140
- capabilities: agent.capabilities,
141
- },
142
- user_id: agent.userId,
143
- }).then(({ error }) => {
144
- if (error)
145
- console.error("[local-agent] audit insert failed:", error.message);
146
- });
223
+ // Telemetry → ClickHouse
224
+ queueSpan(auditRowToSpan({
225
+ action: "local_agent.connected",
226
+ severity: "info",
227
+ store_id: agent.storeId,
228
+ resource_type: "local_agent",
229
+ resource_id: agent.agentId,
230
+ source: "local_agent",
231
+ service_name: "agent-server",
232
+ span_kind: "INTERNAL",
233
+ status_code: "OK",
234
+ start_time: new Date().toISOString(),
235
+ end_time: new Date().toISOString(),
236
+ details: {
237
+ platform: agent.platform,
238
+ hostname: agent.hostname,
239
+ capabilities: agent.capabilities,
240
+ },
241
+ user_id: agent.userId,
242
+ }));
243
+ return;
244
+ }
245
+ // Handle viewer input forwarding (after auth)
246
+ if (authenticated && viewerSession && !agent) {
247
+ // Forward input events from viewer to the target agent
248
+ const target = pickAgentById(viewerSession.agentId);
249
+ if (target?.ws.readyState === WebSocket.OPEN) {
250
+ send(target.ws, { type: "remote_desktop", ...msg });
147
251
  }
148
252
  return;
149
253
  }
@@ -156,6 +260,72 @@ function handleConnection(ws, _req) {
156
260
  agent.lastPong = Date.now();
157
261
  return;
158
262
  }
263
+ // Handle portal messages
264
+ if (msg.type === "portal_request" && agent) {
265
+ // Initiator wants to connect to a target node
266
+ const targetAgent = pickAgent(agent.storeId, undefined, msg.target_node_id);
267
+ if (!targetAgent) {
268
+ send(ws, { type: "portal_reject", session_id: msg.session_id, reason: "Target node not online" });
269
+ return;
270
+ }
271
+ // Forward request to target agent
272
+ send(targetAgent.ws, {
273
+ type: "portal_request",
274
+ session_id: msg.session_id,
275
+ capabilities: msg.capabilities,
276
+ requester_node_id: agent.nodeId,
277
+ requester_hostname: agent.hostname,
278
+ requester_role: msg.requester_role || "admin",
279
+ });
280
+ // Pre-register session so binary routing works once accepted
281
+ portalSessions.set(msg.session_id, {
282
+ sessionId: msg.session_id,
283
+ initiatorAgentId: agent.agentId,
284
+ targetAgentId: targetAgent.agentId,
285
+ storeId: agent.storeId,
286
+ capabilities: msg.capabilities || [],
287
+ startedAt: new Date(),
288
+ bytesRelayed: 0,
289
+ });
290
+ console.log(`[portal] Session ${msg.session_id}: ${agent.hostname} → ${targetAgent.hostname}`);
291
+ return;
292
+ }
293
+ if (msg.type === "portal_accept" && agent) {
294
+ const session = portalSessions.get(msg.session_id);
295
+ if (session) {
296
+ const initiator = pickAgentById(session.initiatorAgentId);
297
+ if (initiator) {
298
+ send(initiator.ws, { type: "portal_accept", session_id: msg.session_id, hostname: agent.hostname });
299
+ }
300
+ }
301
+ return;
302
+ }
303
+ if (msg.type === "portal_reject" && agent) {
304
+ const session = portalSessions.get(msg.session_id);
305
+ if (session) {
306
+ const initiator = pickAgentById(session.initiatorAgentId);
307
+ if (initiator) {
308
+ send(initiator.ws, { type: "portal_reject", session_id: msg.session_id, reason: msg.reason });
309
+ }
310
+ portalSessions.delete(msg.session_id);
311
+ }
312
+ return;
313
+ }
314
+ if (msg.type === "portal_close" && agent) {
315
+ const session = portalSessions.get(msg.session_id);
316
+ if (session) {
317
+ // Notify the other side
318
+ const otherAgentId = agent.agentId === session.initiatorAgentId
319
+ ? session.targetAgentId : session.initiatorAgentId;
320
+ const other = pickAgentById(otherAgentId);
321
+ if (other) {
322
+ send(other.ws, { type: "portal_close", session_id: msg.session_id });
323
+ }
324
+ portalSessions.delete(msg.session_id);
325
+ console.log(`[portal] Session ${msg.session_id} closed (${session.bytesRelayed} bytes relayed)`);
326
+ }
327
+ return;
328
+ }
159
329
  // Handle tool discovery response
160
330
  if (msg.type === "tools") {
161
331
  agent.capabilities = msg.tools;
@@ -196,7 +366,36 @@ function handleConnection(ws, _req) {
196
366
  agentPool.delete(agent.storeId);
197
367
  }
198
368
  console.log(`[local-agent] Agent disconnected: ${agent.agentId} (store=${agent.storeId})`);
199
- // Pending requests for this agent will be cleaned up by their timeouts
369
+ // Immediately fail all pending requests for this agent
370
+ failPendingRequestsForAgent(agent.agentId, `Agent disconnected (${agent.hostname})`);
371
+ // End any portal sessions through this agent
372
+ for (const [sessionId, session] of portalSessions) {
373
+ if (session.initiatorAgentId === agent.agentId || session.targetAgentId === agent.agentId) {
374
+ const otherAgentId = agent.agentId === session.initiatorAgentId
375
+ ? session.targetAgentId : session.initiatorAgentId;
376
+ const other = pickAgentById(otherAgentId);
377
+ if (other) {
378
+ send(other.ws, { type: "portal_close", session_id: sessionId });
379
+ }
380
+ portalSessions.delete(sessionId);
381
+ console.log(`[portal] Session ${sessionId} ended (agent disconnected)`);
382
+ }
383
+ }
384
+ // End any remote desktop sessions through this agent
385
+ for (const [sessionId, session] of rdSessions) {
386
+ if (session.agentId === agent.agentId) {
387
+ send(session.viewerWs, { type: "session_end", reason: "agent_disconnected" });
388
+ session.viewerWs.close(1001, "Agent disconnected");
389
+ rdSessions.delete(sessionId);
390
+ console.log(`[remote-desktop] Session ${sessionId} ended (agent disconnected)`);
391
+ }
392
+ }
393
+ }
394
+ // Clean up viewer sessions
395
+ if (viewerSession) {
396
+ rdSessions.delete(viewerSession.sessionId);
397
+ console.log(`[remote-desktop] Session ${viewerSession.sessionId} ended (viewer disconnected)`);
398
+ viewerSession = null;
200
399
  }
201
400
  });
202
401
  ws.on("error", (err) => {
@@ -210,21 +409,62 @@ async function authenticateAgent(msg) {
210
409
  if (!supabaseClient || !msg.api_key)
211
410
  return null;
212
411
  const keyHash = createHash("sha256").update(msg.api_key).digest("hex");
213
- // Look up API key by hash in api_keys table
412
+ // 1. Try api_keys table first (standard API keys)
214
413
  const { data, error } = await supabaseClient
215
414
  .from("api_keys")
216
415
  .select("id, store_id, owner_user_id")
217
416
  .eq("key_hash", keyHash)
218
417
  .eq("is_active", true)
219
418
  .single();
220
- if (error || !data)
419
+ if (data && !error) {
420
+ // Update last_used_at
421
+ supabaseClient.from("api_keys")
422
+ .update({ last_used_at: new Date().toISOString() })
423
+ .eq("id", data.id)
424
+ .then(() => { });
425
+ return { storeId: data.store_id, userId: data.owner_user_id || null, nodeId: null };
426
+ }
427
+ // 2. Fall back to nodes table (node daemon API keys)
428
+ const { data: nodeData, error: nodeErr } = await supabaseClient
429
+ .from("nodes")
430
+ .select("id, store_id")
431
+ .eq("api_key_hash", keyHash)
432
+ .single();
433
+ if (nodeData && !nodeErr) {
434
+ // Update node last_heartbeat on gateway connect
435
+ supabaseClient.from("nodes")
436
+ .update({ status: "online", last_heartbeat: new Date().toISOString() })
437
+ .eq("id", nodeData.id)
438
+ .then(() => { });
439
+ return { storeId: nodeData.store_id, userId: null, nodeId: nodeData.id };
440
+ }
441
+ return null;
442
+ }
443
+ /**
444
+ * Authenticate a viewer connection using Supabase JWT.
445
+ */
446
+ async function authenticateViewer(msg) {
447
+ if (!supabaseClient || !msg.token)
221
448
  return null;
222
- // Update last_used_at
223
- supabaseClient.from("api_keys")
224
- .update({ last_used_at: new Date().toISOString() })
225
- .eq("id", data.id)
226
- .then(() => { });
227
- return { storeId: data.store_id, userId: data.owner_user_id || null };
449
+ try {
450
+ // Verify JWT with Supabase auth
451
+ const { data: { user }, error } = await supabaseClient.auth.getUser(msg.token);
452
+ if (error || !user)
453
+ return null;
454
+ // Verify user has access to the requested store
455
+ const { data: storeAccess } = await supabaseClient
456
+ .from("store_users")
457
+ .select("store_id")
458
+ .eq("user_id", user.id)
459
+ .eq("store_id", msg.store_id)
460
+ .single();
461
+ if (!storeAccess)
462
+ return null;
463
+ return { storeId: msg.store_id, userId: user.id };
464
+ }
465
+ catch {
466
+ return null;
467
+ }
228
468
  }
229
469
  // ============================================================================
230
470
  // PUBLIC API — used by local-agent handler
@@ -250,6 +490,7 @@ export function getAgentInfo(storeId) {
250
490
  capabilities: a.capabilities,
251
491
  connected_at: a.connectedAt.toISOString(),
252
492
  uptime_seconds: Math.floor((Date.now() - a.connectedAt.getTime()) / 1000),
493
+ node_id: a.nodeId,
253
494
  }));
254
495
  }
255
496
  /**
@@ -265,6 +506,16 @@ export async function executeOnLocalAgent(storeId, command, options = {}) {
265
506
  }
266
507
  const requestId = randomUUID();
267
508
  const timeout = Math.min(options.timeout || 30000, 600000);
509
+ const msg = {
510
+ type: "exec",
511
+ request_id: requestId,
512
+ command,
513
+ session_id: options.session_id,
514
+ timeout,
515
+ };
516
+ const sent = send(agent.ws, msg);
517
+ if (!sent)
518
+ return { success: false, error: "Agent connection closed" };
268
519
  return new Promise((resolve, reject) => {
269
520
  const timer = setTimeout(() => {
270
521
  pendingRequests.delete(requestId);
@@ -273,15 +524,7 @@ export async function executeOnLocalAgent(storeId, command, options = {}) {
273
524
  error: `Local agent command timed out after ${timeout}ms`,
274
525
  });
275
526
  }, timeout + 5000); // 5s buffer over command timeout
276
- pendingRequests.set(requestId, { resolve, reject, timer });
277
- const msg = {
278
- type: "exec",
279
- request_id: requestId,
280
- command,
281
- session_id: options.session_id,
282
- timeout,
283
- };
284
- send(agent.ws, msg);
527
+ pendingRequests.set(requestId, { resolve, reject, timer, agentId: agent.agentId });
285
528
  });
286
529
  }
287
530
  /**
@@ -304,6 +547,16 @@ export async function executeToolOnLocalAgent(storeId, tool, args, options = {})
304
547
  }
305
548
  const requestId = randomUUID();
306
549
  const timeout = Math.min(options.timeout || 30000, 600000);
550
+ const msg = {
551
+ type: "tool_exec",
552
+ request_id: requestId,
553
+ tool,
554
+ args,
555
+ timeout,
556
+ };
557
+ const sent = send(agent.ws, msg);
558
+ if (!sent)
559
+ return { success: false, error: "Agent connection closed" };
307
560
  return new Promise((resolve, reject) => {
308
561
  const timer = setTimeout(() => {
309
562
  pendingRequests.delete(requestId);
@@ -312,15 +565,7 @@ export async function executeToolOnLocalAgent(storeId, tool, args, options = {})
312
565
  error: `Local tool "${tool}" timed out after ${timeout}ms`,
313
566
  });
314
567
  }, timeout + 5000);
315
- pendingRequests.set(requestId, { resolve, reject, timer });
316
- const msg = {
317
- type: "tool_exec",
318
- request_id: requestId,
319
- tool,
320
- args,
321
- timeout,
322
- };
323
- send(agent.ws, msg);
568
+ pendingRequests.set(requestId, { resolve, reject, timer, agentId: agent.agentId });
324
569
  });
325
570
  }
326
571
  /**
@@ -335,6 +580,9 @@ export async function discoverLocalTools(storeId) {
335
580
  };
336
581
  }
337
582
  const requestId = randomUUID();
583
+ const sent = send(agent.ws, { type: "discover", request_id: requestId });
584
+ if (!sent)
585
+ return { success: false, error: "Agent connection closed" };
338
586
  return new Promise((resolve) => {
339
587
  const timer = setTimeout(() => {
340
588
  pendingRequests.delete(requestId);
@@ -344,8 +592,8 @@ export async function discoverLocalTools(storeId) {
344
592
  resolve,
345
593
  reject: () => resolve({ success: false, error: "Discovery failed" }),
346
594
  timer,
595
+ agentId: agent.agentId,
347
596
  });
348
- send(agent.ws, { type: "discover", request_id: requestId });
349
597
  });
350
598
  }
351
599
  /**
@@ -368,10 +616,38 @@ export function getGatewayStats() {
368
616
  agents_by_store: agentsByStore,
369
617
  };
370
618
  }
619
+ /**
620
+ * Get active remote desktop sessions.
621
+ */
622
+ export function getRemoteDesktopSessions() {
623
+ return Array.from(rdSessions.values()).map(s => ({
624
+ session_id: s.sessionId,
625
+ store_id: s.storeId,
626
+ user_id: s.userId,
627
+ agent_id: s.agentId,
628
+ started_at: s.startedAt.toISOString(),
629
+ frames_relayed: s.framesRelayed,
630
+ bytes_relayed: s.bytesRelayed,
631
+ }));
632
+ }
633
+ /**
634
+ * Get active portal sessions.
635
+ */
636
+ export function getPortalSessions() {
637
+ return Array.from(portalSessions.values()).map(s => ({
638
+ session_id: s.sessionId,
639
+ store_id: s.storeId,
640
+ initiator_agent_id: s.initiatorAgentId,
641
+ target_agent_id: s.targetAgentId,
642
+ capabilities: s.capabilities,
643
+ started_at: s.startedAt.toISOString(),
644
+ bytes_relayed: s.bytesRelayed,
645
+ }));
646
+ }
371
647
  // ============================================================================
372
648
  // INTERNALS
373
649
  // ============================================================================
374
- function pickAgent(storeId, agentId) {
650
+ function pickAgent(storeId, agentId, nodeId) {
375
651
  const agents = agentPool.get(storeId.toLowerCase());
376
652
  if (!agents?.length)
377
653
  return null;
@@ -383,12 +659,44 @@ function pickAgent(storeId, agentId) {
383
659
  if (agentId) {
384
660
  return open.find(a => a.agentId === agentId) || null;
385
661
  }
662
+ // Target specific node if requested
663
+ if (nodeId) {
664
+ return open.find(a => a.nodeId === nodeId) || null;
665
+ }
386
666
  // Pick agent with most recent pong (most responsive)
387
667
  return open.reduce((best, a) => a.lastPong > best.lastPong ? a : best);
388
668
  }
669
+ function pickAgentById(agentId) {
670
+ for (const [, agents] of agentPool) {
671
+ const found = agents.find(a => a.agentId === agentId && a.ws.readyState === WebSocket.OPEN);
672
+ if (found)
673
+ return found;
674
+ }
675
+ return null;
676
+ }
677
+ function isJsonString(data) {
678
+ if (typeof data === "string")
679
+ return true;
680
+ if (Buffer.isBuffer(data)) {
681
+ // Quick check: JSON starts with { or [
682
+ return data.length > 0 && (data[0] === 0x7b || data[0] === 0x5b);
683
+ }
684
+ return false;
685
+ }
389
686
  function send(ws, data) {
390
687
  if (ws.readyState === WebSocket.OPEN) {
391
688
  ws.send(JSON.stringify(data));
689
+ return true;
690
+ }
691
+ return false;
692
+ }
693
+ function failPendingRequestsForAgent(agentId, reason) {
694
+ for (const [requestId, pending] of pendingRequests) {
695
+ if (pending.agentId === agentId) {
696
+ clearTimeout(pending.timer);
697
+ pendingRequests.delete(requestId);
698
+ pending.resolve({ success: false, error: reason });
699
+ }
392
700
  }
393
701
  }
394
702
  function truncate(text) {
@@ -396,6 +704,38 @@ function truncate(text) {
396
704
  return text;
397
705
  return text.substring(0, MAX_OUTPUT_CHARS) + `\n...[truncated, ${text.length} total chars]`;
398
706
  }
707
+ /**
708
+ * Execute a cluster command on the connected whale-node.
709
+ * Sends via WebSocket and waits for the result.
710
+ */
711
+ export async function executeClusterCommand(storeId, args, options = {}) {
712
+ const agent = pickAgent(storeId, options.agent_id, options.node_id);
713
+ if (!agent) {
714
+ return {
715
+ success: false,
716
+ error: "No local agent connected for cluster commands.",
717
+ };
718
+ }
719
+ const requestId = randomUUID();
720
+ const timeout = Math.min(options.timeout || 60000, 600000);
721
+ const sent = send(agent.ws, {
722
+ type: "cluster_command",
723
+ request_id: requestId,
724
+ args,
725
+ });
726
+ if (!sent)
727
+ return { success: false, error: "Agent connection closed" };
728
+ return new Promise((resolve, reject) => {
729
+ const timer = setTimeout(() => {
730
+ pendingRequests.delete(requestId);
731
+ resolve({
732
+ success: false,
733
+ error: `Cluster command timed out after ${timeout}ms`,
734
+ });
735
+ }, timeout + 5000);
736
+ pendingRequests.set(requestId, { resolve, reject, timer, agentId: agent.agentId });
737
+ });
738
+ }
399
739
  // ============================================================================
400
740
  // SHUTDOWN
401
741
  // ============================================================================
@@ -67,8 +67,18 @@ export class AnthropicAdapter {
67
67
  }
68
68
  if (effectiveBetas.length)
69
69
  apiParams.betas = effectiveBetas;
70
- if (context_management?.edits?.length)
71
- apiParams.context_management = context_management;
70
+ // Safety: strip clear_thinking_20251015 edits when thinking is not enabled.
71
+ // Anthropic returns 400 if this strategy is present without thinking=enabled/adaptive.
72
+ let safeContextMgmt = context_management;
73
+ if (safeContextMgmt?.edits?.length && !thinking) {
74
+ const filtered = safeContextMgmt.edits.filter((e) => e.type !== "clear_thinking_20251015");
75
+ if (filtered.length !== safeContextMgmt.edits.length) {
76
+ console.log(`[proxy] Stripped ${safeContextMgmt.edits.length - filtered.length} clear_thinking edit(s) — thinking not enabled`);
77
+ safeContextMgmt = { ...safeContextMgmt, edits: filtered };
78
+ }
79
+ }
80
+ if (safeContextMgmt?.edits?.length)
81
+ apiParams.context_management = safeContextMgmt;
72
82
  if (thinking)
73
83
  apiParams.thinking = thinking;
74
84
  // Map tool_choice to Anthropic format
@@ -34,7 +34,7 @@ const MODEL_MAX_OUTPUT_TOKENS = {
34
34
  // ============================================================================
35
35
  /** Convert Anthropic messages to Gemini Content format */
36
36
  function anthropicToGeminiMessages(messages) {
37
- const contents = [];
37
+ const raw = [];
38
38
  // Track tool_use_id → name for tool_result conversion
39
39
  const toolIdMap = new Map();
40
40
  for (const msg of messages) {
@@ -86,7 +86,22 @@ function anthropicToGeminiMessages(messages) {
86
86
  }
87
87
  }
88
88
  if (parts.length > 0) {
89
- contents.push({ role, parts });
89
+ raw.push({ role, parts });
90
+ }
91
+ }
92
+ // Gemini requires strictly alternating user/model turns.
93
+ // After context compaction or multi-step tool use, consecutive same-role turns
94
+ // can appear — Gemini rejects these with INVALID_ARGUMENT.
95
+ // Fix: merge consecutive same-role turns by concatenating their parts.
96
+ const contents = [];
97
+ for (const turn of raw) {
98
+ const prev = contents[contents.length - 1];
99
+ if (prev && prev.role === turn.role) {
100
+ // Merge into the previous turn
101
+ prev.parts = [...(prev.parts || []), ...(turn.parts || [])];
102
+ }
103
+ else {
104
+ contents.push({ role: turn.role, parts: turn.parts });
90
105
  }
91
106
  }
92
107
  return contents;