@agent-relay/dashboard-server 2.0.82 → 2.0.83-beta.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 (65) hide show
  1. package/dist/server.d.ts.map +1 -1
  2. package/dist/server.js +242 -127
  3. package/dist/server.js.map +1 -1
  4. package/dist/services/broker-spawn-reader.d.ts +40 -0
  5. package/dist/services/broker-spawn-reader.d.ts.map +1 -0
  6. package/dist/services/broker-spawn-reader.js +154 -0
  7. package/dist/services/broker-spawn-reader.js.map +1 -0
  8. package/dist/types/index.d.ts +8 -1
  9. package/dist/types/index.d.ts.map +1 -1
  10. package/out/404.html +1 -1
  11. package/out/about.html +1 -1
  12. package/out/about.txt +1 -1
  13. package/out/app/onboarding.html +1 -1
  14. package/out/app/onboarding.txt +1 -1
  15. package/out/app.html +1 -1
  16. package/out/app.txt +1 -1
  17. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +1 -1
  18. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  19. package/out/blog/let-them-cook-multi-agent-orchestration.html +1 -1
  20. package/out/blog/let-them-cook-multi-agent-orchestration.txt +1 -1
  21. package/out/blog.html +1 -1
  22. package/out/blog.txt +1 -1
  23. package/out/careers.html +1 -1
  24. package/out/careers.txt +1 -1
  25. package/out/changelog.html +1 -1
  26. package/out/changelog.txt +1 -1
  27. package/out/cloud/link.html +1 -1
  28. package/out/cloud/link.txt +1 -1
  29. package/out/complete-profile.html +1 -1
  30. package/out/complete-profile.txt +1 -1
  31. package/out/connect-repos.html +1 -1
  32. package/out/connect-repos.txt +1 -1
  33. package/out/contact.html +1 -1
  34. package/out/contact.txt +1 -1
  35. package/out/docs.html +1 -1
  36. package/out/docs.txt +1 -1
  37. package/out/history.html +1 -1
  38. package/out/history.txt +1 -1
  39. package/out/index.html +1 -1
  40. package/out/index.txt +1 -1
  41. package/out/login.html +1 -1
  42. package/out/login.txt +1 -1
  43. package/out/metrics.html +1 -1
  44. package/out/metrics.txt +1 -1
  45. package/out/pricing.html +1 -1
  46. package/out/pricing.txt +1 -1
  47. package/out/privacy.html +1 -1
  48. package/out/privacy.txt +1 -1
  49. package/out/providers/setup/claude.html +1 -1
  50. package/out/providers/setup/claude.txt +1 -1
  51. package/out/providers/setup/codex.html +1 -1
  52. package/out/providers/setup/codex.txt +1 -1
  53. package/out/providers/setup/cursor.html +1 -1
  54. package/out/providers/setup/cursor.txt +1 -1
  55. package/out/providers.html +1 -1
  56. package/out/providers.txt +1 -1
  57. package/out/security.html +1 -1
  58. package/out/security.txt +1 -1
  59. package/out/signup.html +1 -1
  60. package/out/signup.txt +1 -1
  61. package/out/terms.html +1 -1
  62. package/out/terms.txt +1 -1
  63. package/package.json +1 -1
  64. /package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → TnAI-TAQ4-bNJRL3Ln3NB}/_buildManifest.js +0 -0
  65. /package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → TnAI-TAQ4-bNJRL3Ln3NB}/_ssgManifest.js +0 -0
package/dist/server.js CHANGED
@@ -18,6 +18,7 @@ import { listTrajectorySteps, getTrajectoryStatus, getTrajectoryHistory } from '
18
18
  import { loadTeamsConfig } from '@agent-relay/config';
19
19
  import { getMemoryMonitor } from '@agent-relay/resiliency';
20
20
  import { detectWorkspacePath, getAgentOutboxTemplate } from '@agent-relay/config';
21
+ import { BrokerSpawnReader } from './services/broker-spawn-reader.js';
21
22
  // Dynamically find the dashboard static files directory
22
23
  // We can't import from @agent-relay/dashboard directly due to circular dependency
23
24
  function findDashboardDir() {
@@ -320,6 +321,24 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
320
321
  ? { port: portOrOptions, dataDir: dataDirArg, teamDir: teamDirArg, dbPath: dbPathArg }
321
322
  : portOrOptions;
322
323
  const { port, dataDir, teamDir, dbPath, enableSpawner, projectRoot, tmuxSession, onMarkSpawning, onClearSpawning, verbose, spawnManager: externalSpawnManager } = options;
324
+ let { relayAdapter } = options;
325
+ // Auto-create a RelayAdapter when projectRoot is available and no adapter/spawnManager is passed.
326
+ // This makes the dashboard self-contained — `relay-dashboard-server --integrated --project-root .`
327
+ // works without the CLI.
328
+ if (!relayAdapter && !externalSpawnManager && enableSpawner && projectRoot) {
329
+ try {
330
+ const brokerSdk = await import('@agent-relay/broker-sdk');
331
+ relayAdapter = new brokerSdk.RelayAdapter({
332
+ cwd: projectRoot,
333
+ clientName: 'dashboard',
334
+ });
335
+ console.log('[dashboard] Auto-created RelayAdapter for broker mode');
336
+ }
337
+ catch {
338
+ // @agent-relay/broker-sdk not installed — fall back to legacy AgentSpawner
339
+ }
340
+ }
341
+ const useBrokerAdapter = !!relayAdapter;
323
342
  // Debug logging helper - only logs when verbose is true or VERBOSE env var is set
324
343
  const isVerbose = verbose || process.env.VERBOSE === 'true';
325
344
  const debug = (message) => {
@@ -487,31 +506,41 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
487
506
  // tool directories like ~/.nvm contain package.json markers.
488
507
  const workspacePath = projectRoot || detectWorkspacePath(dataDir);
489
508
  console.log(`[dashboard] Workspace path: ${workspacePath}`);
490
- // When an external SpawnManager is provided (from the daemon), use it for read operations
491
- // (logs, worker listing, hasWorker) and route spawn/release through the SDK client.
492
- // This solves relay-pty binary resolution issues in npx/global installs.
493
- // Fall back to creating a local AgentSpawner only when no SpawnManager is provided.
509
+ // Spawner / SpawnReader setup three modes:
510
+ // 1. Broker adapter mode: relayAdapter provides spawn/release/list via Rust binary stdio
511
+ // 2. External SpawnManager: daemon co-located, read ops from daemon's SpawnManager
512
+ // 3. Local AgentSpawner: standalone mode, spawns agents directly
494
513
  const useExternalSpawnManager = !!externalSpawnManager;
495
- const spawner = enableSpawner && !useExternalSpawnManager
496
- ? new AgentSpawner({
514
+ let brokerSpawnReader;
515
+ let spawner;
516
+ if (useBrokerAdapter) {
517
+ // Mode 1: Broker adapter — create BrokerSpawnReader backed by RelayAdapter
518
+ brokerSpawnReader = new BrokerSpawnReader(relayAdapter);
519
+ await relayAdapter.start();
520
+ await brokerSpawnReader.initialize();
521
+ console.log('[dashboard] Using broker adapter for spawn operations');
522
+ }
523
+ else if (!useExternalSpawnManager && enableSpawner) {
524
+ // Mode 3: Local AgentSpawner (legacy standalone)
525
+ spawner = new AgentSpawner({
497
526
  projectRoot: workspacePath,
498
527
  tmuxSession,
499
528
  dashboardPort: port,
500
529
  onMarkSpawning,
501
530
  onClearSpawning,
502
- })
503
- : undefined;
531
+ });
532
+ }
504
533
  // spawnReader provides read-only access to spawner state.
505
- // Uses the external SpawnManager when available (daemon co-located),
506
- // otherwise falls back to the local AgentSpawner.
507
- const spawnReader = externalSpawnManager || spawner;
508
- if (useExternalSpawnManager) {
534
+ // Priority: broker adapter > external SpawnManager > local AgentSpawner
535
+ const spawnReader = brokerSpawnReader || externalSpawnManager || spawner;
536
+ if (useBrokerAdapter) {
537
+ // Already logged above
538
+ }
539
+ else if (useExternalSpawnManager) {
509
540
  console.log('[dashboard] Using daemon SpawnManager for spawn operations (SDK-routed)');
510
541
  }
511
- // Initialize cloud persistence and memory monitoring if enabled (RELAY_CLOUD_ENABLED=true)
512
- // Only needed for local AgentSpawner; daemon's SpawnManager handles its own cloud persistence
513
- if (spawner && !useExternalSpawnManager) {
514
- // Use workspace ID from env or generate from project root
542
+ // Initialize cloud persistence (only for legacy local AgentSpawner mode)
543
+ if (spawner && !useExternalSpawnManager && !useBrokerAdapter) {
515
544
  const workspaceId = process.env.RELAY_WORKSPACE_ID ||
516
545
  crypto.createHash('sha256').update(projectRoot || dataDir).digest('hex').slice(0, 36);
517
546
  initCloudPersistence(workspaceId).then((cloudHandler) => {
@@ -522,17 +551,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
522
551
  console.warn('[dashboard] Failed to initialize cloud persistence:', err);
523
552
  });
524
553
  }
525
- // Initialize memory monitoring for cloud deployments
526
- if (spawnReader && (process.env.RELAY_CLOUD_ENABLED === 'true' || process.env.RELAY_MEMORY_MONITORING === 'true')) {
554
+ // Initialize memory monitoring for cloud deployments (skip in broker mode — use adapter.getMetrics())
555
+ if (!useBrokerAdapter && spawnReader && (process.env.RELAY_CLOUD_ENABLED === 'true' || process.env.RELAY_MEMORY_MONITORING === 'true')) {
527
556
  try {
528
557
  const memoryMonitor = getMemoryMonitor({
529
- checkIntervalMs: 10000, // Check every 10 seconds
558
+ checkIntervalMs: 10000,
530
559
  enableTrendAnalysis: true,
531
560
  enableProactiveAlerts: true,
532
561
  });
533
562
  memoryMonitor.start();
534
563
  console.log('[dashboard] Memory monitoring enabled');
535
- // Register existing workers with memory monitor
536
564
  const workers = spawnReader.getActiveWorkers();
537
565
  for (const worker of workers) {
538
566
  if (worker.pid) {
@@ -882,13 +910,56 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
882
910
  });
883
911
  }
884
912
  // Relay clients for sending messages from dashboard
885
- // Map of senderName -> RelayClient for per-user connections
913
+ // In broker adapter mode, messages go through the single RelayAdapter.
914
+ // In legacy mode, per-user RelayClient connections are created via socket.
886
915
  const socketPath = path.join(dataDir, 'relay.sock');
887
916
  const relayClients = new Map();
888
917
  // Forward declaration - initialized later, used by getRelayClient to avoid duplicate connections
889
918
  // eslint-disable-next-line prefer-const
890
919
  let userBridge;
920
+ // In broker mode, create shim objects that satisfy the RelayClient interface
921
+ // used by the rest of the server (sendMessage, state, spawn, release).
922
+ // Each shim captures a sender name for proper message attribution.
923
+ const brokerClientShimCache = new Map();
924
+ const getBrokerClientShim = (senderName) => {
925
+ if (!useBrokerAdapter)
926
+ return undefined;
927
+ let shim = brokerClientShimCache.get(senderName);
928
+ if (!shim) {
929
+ shim = {
930
+ state: 'READY',
931
+ sendMessage: async (to, body, _kind, _data, thread) => {
932
+ await relayAdapter.sendMessage({ to, text: body, from: senderName, threadId: thread });
933
+ return true;
934
+ },
935
+ spawn: async (req) => {
936
+ return relayAdapter.spawn({
937
+ name: req.name,
938
+ cli: req.cli,
939
+ task: req.task,
940
+ team: req.team,
941
+ cwd: req.cwd,
942
+ interactive: req.interactive,
943
+ shadowMode: req.shadowMode,
944
+ shadowOf: req.shadowOf,
945
+ spawnerName: req.spawnerName,
946
+ userId: req.userId,
947
+ includeWorkflowConventions: req.includeWorkflowConventions,
948
+ });
949
+ },
950
+ release: async (name) => {
951
+ return relayAdapter.release(name);
952
+ },
953
+ };
954
+ brokerClientShimCache.set(senderName, shim);
955
+ }
956
+ return shim;
957
+ };
891
958
  const notifyDaemonOfMembershipUpdate = async (channel, member, action, workspaceId) => {
959
+ if (useBrokerAdapter) {
960
+ // In broker mode, channel membership is managed differently — no _router message needed
961
+ return;
962
+ }
892
963
  const client = await getRelayClient('Dashboard');
893
964
  if (!client || client.state !== 'READY') {
894
965
  return;
@@ -905,14 +976,20 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
905
976
  // Track pending client connections to prevent race conditions
906
977
  const pendingConnections = new Map();
907
978
  // Get or create a relay client for a specific sender
979
+ // In broker adapter mode, returns a shim backed by RelayAdapter.
980
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
908
981
  const getRelayClient = async (senderName = 'Dashboard', entityType) => {
982
+ // Broker adapter mode — return per-sender shim (always "ready")
983
+ if (useBrokerAdapter) {
984
+ return getBrokerClientShim(senderName);
985
+ }
986
+ // --- Legacy socket-based mode below ---
909
987
  // Check if we already have a connected client for this sender
910
988
  const existing = relayClients.get(senderName);
911
989
  if (existing && existing.state === 'READY') {
912
990
  return existing;
913
991
  }
914
992
  // Check if userBridge has a client for this user (avoid duplicate connections)
915
- // This prevents the connection storm where two clients fight for the same name
916
993
  if (userBridge) {
917
994
  const userBridgeClient = userBridge.getRelayClient(senderName);
918
995
  if (userBridgeClient && userBridgeClient.state === 'READY') {
@@ -932,9 +1009,6 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
932
1009
  }
933
1010
  // Create connection promise to prevent race conditions
934
1011
  const connectionPromise = (async () => {
935
- // Create new client for this sender
936
- // Default to 'user' entityType for non-Dashboard senders (human users)
937
- // System clients (starting with '_') should NOT be users - they're internal clients
938
1012
  const isSystemClient = senderName.startsWith('_') || senderName === 'Dashboard';
939
1013
  const resolvedEntityType = entityType ?? (isSystemClient ? undefined : 'user');
940
1014
  const client = new RelayClient({
@@ -944,7 +1018,6 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
944
1018
  cli: 'dashboard',
945
1019
  reconnect: true,
946
1020
  maxReconnectAttempts: 5,
947
- // Dashboard is a reserved name, so we need to mark it as a system component
948
1021
  _isSystemComponent: senderName === 'Dashboard',
949
1022
  });
950
1023
  client.onError = (err) => {
@@ -952,28 +1025,17 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
952
1025
  };
953
1026
  client.onStateChange = (state) => {
954
1027
  console.log(`[dashboard] Relay client for ${senderName} state: ${state}`);
955
- // Handle state transitions
956
1028
  if (state === 'READY') {
957
- // Re-add to map on successful reconnection
958
1029
  relayClients.set(senderName, client);
959
1030
  }
960
1031
  else if (state === 'DISCONNECTED') {
961
- // Only remove if max reconnect attempts exhausted (client gives up)
962
- // The client will auto-reconnect if configured, so don't remove prematurely
963
1032
  relayClients.delete(senderName);
964
1033
  }
965
1034
  };
966
- // Set up channel message handler to forward messages to presence WebSocket
967
- // This enables cloud users to receive channel messages via the presence bridge
968
1035
  client.onChannelMessage = (from, channel, body, envelope) => {
969
- console.log(`[dashboard] *** CHANNEL MESSAGE RECEIVED *** for ${senderName}: ${from} -> ${channel}`);
970
- // Look up sender's avatar from presence (if they're an online user)
971
1036
  const senderPresence = onlineUsers.get(from);
972
1037
  const fromAvatarUrl = senderPresence?.info.avatarUrl;
973
- // Determine entity type: user if they have presence state, agent otherwise
974
1038
  const fromEntityType = senderPresence ? 'user' : 'agent';
975
- // Broadcast to presence WebSocket clients so cloud can forward to its users
976
- // Include the target user so cloud knows who to forward to
977
1039
  broadcastChannelMessage({
978
1040
  type: 'channel_message',
979
1041
  targetUser: senderName,
@@ -987,24 +1049,14 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
987
1049
  timestamp: new Date().toISOString(),
988
1050
  });
989
1051
  };
990
- // Set up direct message handler to forward messages to presence WebSocket
991
- // This enables agents to send replies that appear in the dashboard UI
992
- // Note: the relay daemon already persists messages authoritatively, so we
993
- // only need to broadcast the real-time event here (no storage.saveMessage).
994
1052
  client.onMessage = (from, payload, messageId) => {
995
1053
  const body = typeof payload === 'object' && payload !== null && 'body' in payload
996
1054
  ? payload.body
997
1055
  : String(payload);
998
- console.log(`[dashboard] *** DIRECT MESSAGE RECEIVED *** for ${senderName}: ${from} -> ${senderName}: ${body.substring(0, 50)}...`);
999
- // Look up sender's info from presence (if they're an online user)
1000
1056
  const senderPresence = onlineUsers.get(from);
1001
1057
  const fromAvatarUrl = senderPresence?.info.avatarUrl;
1002
- // Determine entity type: user if they have presence state, agent otherwise
1003
1058
  const fromEntityType = senderPresence ? 'user' : 'agent';
1004
1059
  const timestamp = new Date().toISOString();
1005
- // Broadcast real-time event so the dashboard UI updates immediately.
1006
- // Pass id (= messageId) so the client has a stable identifier and
1007
- // doesn't need to fabricate one from Date.now().
1008
1060
  broadcastDirectMessage({
1009
1061
  type: 'direct_message',
1010
1062
  targetUser: senderName,
@@ -1028,54 +1080,96 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1028
1080
  return undefined;
1029
1081
  }
1030
1082
  finally {
1031
- // Clean up pending connection
1032
1083
  pendingConnections.delete(senderName);
1033
1084
  }
1034
1085
  })();
1035
- // Store the pending connection
1036
1086
  pendingConnections.set(senderName, connectionPromise);
1037
1087
  return connectionPromise;
1038
1088
  };
1039
- // Start default relay client connection (non-blocking)
1040
- // Use 'Dashboard' to avoid conflicts with agents named 'Dashboard'
1041
- getRelayClient('Dashboard').catch(() => { });
1042
- // User bridge for human-to-human and human-to-agent messaging
1043
- userBridge = new UserBridge({
1044
- socketPath,
1045
- createRelayClient: async (options) => {
1046
- const client = new RelayClient({
1047
- socketPath: options.socketPath,
1048
- agentName: options.agentName,
1049
- entityType: options.entityType,
1050
- displayName: options.displayName,
1051
- avatarUrl: options.avatarUrl,
1052
- cli: 'dashboard',
1053
- reconnect: true,
1054
- maxReconnectAttempts: 5,
1055
- });
1056
- client.onError = (err) => {
1057
- console.error(`[user-bridge] Relay client error for ${options.agentName}:`, err.message);
1058
- };
1059
- await client.connect();
1060
- return client;
1061
- },
1062
- loadPersistedChannels: (username) => loadPersistedChannelsForUser(username, defaultWorkspaceId),
1063
- // Look up user info (avatar URL) from presence
1064
- lookupUserInfo: (username) => {
1065
- const presence = onlineUsers.get(username);
1066
- if (presence) {
1067
- return { avatarUrl: presence.info.avatarUrl };
1089
+ // In broker mode, subscribe to relay_inbound events for message forwarding
1090
+ if (useBrokerAdapter) {
1091
+ relayAdapter.onEvent((event) => {
1092
+ if (event.kind === 'relay_inbound') {
1093
+ const senderPresence = onlineUsers.get(event.from);
1094
+ const fromAvatarUrl = senderPresence?.info.avatarUrl;
1095
+ const fromEntityType = senderPresence ? 'user' : 'agent';
1096
+ const timestamp = new Date().toISOString();
1097
+ // Route to channel or direct message based on target
1098
+ if (event.target.startsWith('#')) {
1099
+ broadcastChannelMessage({
1100
+ type: 'channel_message',
1101
+ targetUser: event.target,
1102
+ channel: event.target,
1103
+ from: event.from,
1104
+ fromAvatarUrl,
1105
+ fromEntityType,
1106
+ body: event.body,
1107
+ thread: event.thread_id,
1108
+ timestamp,
1109
+ });
1110
+ }
1111
+ else {
1112
+ broadcastDirectMessage({
1113
+ type: 'direct_message',
1114
+ targetUser: event.target,
1115
+ from: event.from,
1116
+ fromAvatarUrl,
1117
+ fromEntityType,
1118
+ body: event.body,
1119
+ id: event.event_id,
1120
+ messageId: event.event_id,
1121
+ timestamp,
1122
+ });
1123
+ }
1068
1124
  }
1069
- return undefined;
1070
- },
1071
- });
1125
+ });
1126
+ console.log('[dashboard] Broker event subscription active for message forwarding');
1127
+ }
1128
+ // Start default relay client connection (non-blocking) — skip in broker mode
1129
+ if (!useBrokerAdapter) {
1130
+ getRelayClient('Dashboard').catch(() => { });
1131
+ }
1132
+ // User bridge for human-to-human and human-to-agent messaging
1133
+ // In broker mode, skip UserBridge — messages go through the single adapter
1134
+ if (!useBrokerAdapter) {
1135
+ userBridge = new UserBridge({
1136
+ socketPath,
1137
+ createRelayClient: async (options) => {
1138
+ const client = new RelayClient({
1139
+ socketPath: options.socketPath,
1140
+ agentName: options.agentName,
1141
+ entityType: options.entityType,
1142
+ displayName: options.displayName,
1143
+ avatarUrl: options.avatarUrl,
1144
+ cli: 'dashboard',
1145
+ reconnect: true,
1146
+ maxReconnectAttempts: 5,
1147
+ });
1148
+ client.onError = (err) => {
1149
+ console.error(`[user-bridge] Relay client error for ${options.agentName}:`, err.message);
1150
+ };
1151
+ await client.connect();
1152
+ return client;
1153
+ },
1154
+ loadPersistedChannels: (username) => loadPersistedChannelsForUser(username, defaultWorkspaceId),
1155
+ lookupUserInfo: (username) => {
1156
+ const presence = onlineUsers.get(username);
1157
+ if (presence) {
1158
+ return { avatarUrl: presence.info.avatarUrl };
1159
+ }
1160
+ return undefined;
1161
+ },
1162
+ });
1163
+ }
1072
1164
  // Bridge client for cross-project messaging
1165
+ // In broker mode, cross-project messaging goes through Relaycast cloud — skip socket bridge
1073
1166
  let bridgeClient;
1074
1167
  let bridgeClientConnecting = false;
1075
1168
  const connectBridgeClient = async () => {
1169
+ if (useBrokerAdapter)
1170
+ return; // Cross-project messaging via Relaycast cloud in broker mode
1076
1171
  if (bridgeClient || bridgeClientConnecting)
1077
1172
  return;
1078
- // Check if bridge-state.json exists and has projects
1079
1173
  const bridgeStatePath = path.join(dataDir, 'bridge-state.json');
1080
1174
  if (!fs.existsSync(bridgeStatePath)) {
1081
1175
  return;
@@ -1086,28 +1180,25 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1086
1180
  return;
1087
1181
  }
1088
1182
  bridgeClientConnecting = true;
1089
- // Build project configs from bridge state
1090
1183
  const projectConfigs = bridgeState.projects.map((p) => {
1091
- // Compute socket path for each project
1092
1184
  const projectHash = crypto.createHash('sha256').update(p.path).digest('hex').slice(0, 12);
1093
1185
  const projectDataDir = path.join(path.dirname(dataDir), projectHash);
1094
- const socketPath = path.join(projectDataDir, 'relay.sock');
1186
+ const projectSocketPath = path.join(projectDataDir, 'relay.sock');
1095
1187
  return {
1096
1188
  id: p.id,
1097
1189
  path: p.path,
1098
- socketPath,
1190
+ socketPath: projectSocketPath,
1099
1191
  leadName: p.lead?.name || 'Lead',
1100
1192
  cli: 'dashboard-bridge',
1101
1193
  };
1102
1194
  });
1103
- // Filter to projects with existing sockets
1104
1195
  const validConfigs = projectConfigs.filter((p) => fs.existsSync(p.socketPath));
1105
1196
  if (validConfigs.length === 0) {
1106
1197
  bridgeClientConnecting = false;
1107
1198
  return;
1108
1199
  }
1109
1200
  bridgeClient = new MultiProjectClient(validConfigs, {
1110
- agentName: '__DashboardBridge__', // Unique name to avoid conflict with CLI bridge
1201
+ agentName: '__DashboardBridge__',
1111
1202
  reconnect: true,
1112
1203
  });
1113
1204
  bridgeClient.onProjectStateChange = (projectId, connected) => {
@@ -1183,7 +1274,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1183
1274
  if (username === '*')
1184
1275
  return true;
1185
1276
  // Check local presence, userBridge registration, and remote users from cloud
1186
- return onlineUsers.has(username) || userBridge.isUserRegistered(username) || isRemoteUser(username);
1277
+ return onlineUsers.has(username) || userBridge?.isUserRegistered(username) || isRemoteUser(username);
1187
1278
  };
1188
1279
  const isRecipientOnline = (name) => (isAgentOnline(name) || isRemoteAgent(name) || isUserOnline(name));
1189
1280
  // Helper to get team members from teams.json, agents.json, and spawner's active workers
@@ -2437,7 +2528,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2437
2528
  console.error(`[dashboard] Error subscribing to ${agentName}:`, err);
2438
2529
  });
2439
2530
  }
2440
- ws.on('message', (data) => {
2531
+ ws.on('message', async (data) => {
2441
2532
  try {
2442
2533
  const msg = JSON.parse(data.toString());
2443
2534
  // Subscribe to agent logs
@@ -2480,7 +2571,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2480
2571
  }
2481
2572
  // Check if this is a spawned agent (we can only send input to spawned agents)
2482
2573
  if (spawnReader?.hasWorker(agentName)) {
2483
- const success = spawnReader.sendWorkerInput(agentName, msg.data);
2574
+ const success = await spawnReader.sendWorkerInput(agentName, msg.data);
2484
2575
  if (!success) {
2485
2576
  console.warn(`[dashboard] Failed to send input to agent ${agentName}`);
2486
2577
  }
@@ -2730,7 +2821,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2730
2821
  existing.info.lastSeen = now;
2731
2822
  // Update userBridge to use the new WebSocket for message delivery
2732
2823
  // This ensures messages are sent to an active connection, not a stale one
2733
- userBridge.updateWebSocket(username, ws);
2824
+ userBridge?.updateWebSocket(username, ws);
2734
2825
  // Only log at milestones to reduce noise
2735
2826
  const count = existing.connections.size;
2736
2827
  if (count === 2 || count === 5 || count === 10 || count % 50 === 0) {
@@ -2750,7 +2841,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2750
2841
  });
2751
2842
  console.log(`[dashboard] User ${username} came online`);
2752
2843
  // Register user with relay daemon for messaging
2753
- userBridge.registerUser(username, ws, { avatarUrl }).catch((err) => {
2844
+ userBridge?.registerUser(username, ws, { avatarUrl }).catch((err) => {
2754
2845
  console.error(`[dashboard] Failed to register user ${username} with relay:`, err);
2755
2846
  });
2756
2847
  // Broadcast join to all other clients (only for truly new users)
@@ -2836,7 +2927,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2836
2927
  console.warn(`[dashboard] Invalid channel_join: missing channel`);
2837
2928
  return;
2838
2929
  }
2839
- userBridge.joinChannel(clientUsername, msg.channel).then((success) => {
2930
+ userBridge?.joinChannel(clientUsername, msg.channel).then((success) => {
2840
2931
  ws.send(JSON.stringify({
2841
2932
  type: 'channel_joined',
2842
2933
  channel: msg.channel,
@@ -2862,7 +2953,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2862
2953
  console.warn(`[dashboard] Invalid channel_leave: missing channel`);
2863
2954
  return;
2864
2955
  }
2865
- userBridge.leaveChannel(clientUsername, msg.channel).then((success) => {
2956
+ userBridge?.leaveChannel(clientUsername, msg.channel).then((success) => {
2866
2957
  ws.send(JSON.stringify({
2867
2958
  type: 'channel_left',
2868
2959
  channel: msg.channel,
@@ -2886,7 +2977,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2886
2977
  console.warn(`[dashboard] Invalid channel_message: missing body`);
2887
2978
  return;
2888
2979
  }
2889
- userBridge.sendChannelMessage(clientUsername, msg.channel, msg.body, {
2980
+ userBridge?.sendChannelMessage(clientUsername, msg.channel, msg.body, {
2890
2981
  thread: msg.thread,
2891
2982
  }).catch((err) => {
2892
2983
  console.error(`[dashboard] Channel message error:`, err);
@@ -2906,7 +2997,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2906
2997
  console.warn(`[dashboard] Invalid direct_message: missing body`);
2907
2998
  return;
2908
2999
  }
2909
- userBridge.sendDirectMessage(clientUsername, msg.to, msg.body, {
3000
+ userBridge?.sendDirectMessage(clientUsername, msg.to, msg.body, {
2910
3001
  thread: msg.thread,
2911
3002
  }).catch((err) => {
2912
3003
  console.error(`[dashboard] Direct message error:`, err);
@@ -2931,7 +3022,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2931
3022
  onlineUsers.delete(clientUsername);
2932
3023
  console.log(`[dashboard] User ${clientUsername} disconnected`);
2933
3024
  // Unregister from relay daemon
2934
- userBridge.unregisterUser(clientUsername);
3025
+ userBridge?.unregisterUser(clientUsername);
2935
3026
  broadcastPresence({
2936
3027
  type: 'presence_leave',
2937
3028
  username: clientUsername,
@@ -2964,7 +3055,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2964
3055
  if (!username) {
2965
3056
  return res.status(400).json({ error: 'username query param required' });
2966
3057
  }
2967
- const channels = userBridge.getUserChannels(username);
3058
+ const channels = userBridge?.getUserChannels(username) ?? [];
2968
3059
  return res.json({
2969
3060
  channels: channels.map((id) => ({
2970
3061
  id,
@@ -3083,14 +3174,14 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3083
3174
  // Join the creator to the channel
3084
3175
  // Note: userBridge.joinChannel triggers router's persistChannelMembership via protocol
3085
3176
  // We only persist here for dashboard-initiated creates (no daemon connection)
3086
- await userBridge.joinChannel(username, channelId);
3177
+ await userBridge?.joinChannel(username, channelId);
3087
3178
  await persistChannelMembershipEvent(channelId, username, 'join', { workspaceId });
3088
3179
  // Handle invites if provided
3089
3180
  if (invites) {
3090
3181
  const inviteList = invites.split(',').map((s) => s.trim()).filter(Boolean);
3091
3182
  for (const invitee of inviteList) {
3092
3183
  // userBridge.joinChannel handles persistence via protocol
3093
- await userBridge.joinChannel(invitee, channelId);
3184
+ await userBridge?.joinChannel(invitee, channelId);
3094
3185
  await persistChannelMembershipEvent(channelId, invitee, 'invite', { invitedBy: username, workspaceId });
3095
3186
  }
3096
3187
  }
@@ -3176,8 +3267,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3176
3267
  for (const invitee of inviteList) {
3177
3268
  let success = false;
3178
3269
  let reason;
3179
- if (userBridge.isUserRegistered(invitee.id)) {
3180
- success = await userBridge.joinChannel(invitee.id, channelId);
3270
+ if (userBridge?.isUserRegistered(invitee.id)) {
3271
+ success = await userBridge?.joinChannel(invitee.id, channelId);
3181
3272
  if (!success) {
3182
3273
  reason = 'join_failed';
3183
3274
  }
@@ -3203,7 +3294,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3203
3294
  * GET /api/channels/users - Get list of registered users
3204
3295
  */
3205
3296
  app.get('/api/channels/users', (_req, res) => {
3206
- const users = userBridge.getRegisteredUsers();
3297
+ const users = userBridge?.getRegisteredUsers() ?? [];
3207
3298
  res.json({ users });
3208
3299
  });
3209
3300
  /**
@@ -3223,11 +3314,11 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3223
3314
  : (channel.startsWith('#') ? channel : `#${channel}`);
3224
3315
  let success = false;
3225
3316
  // Step 1: Try userBridge (for users connected via local WebSocket)
3226
- const isLocalUser = userBridge.isUserRegistered(username);
3317
+ const isLocalUser = userBridge?.isUserRegistered(username);
3227
3318
  console.log(`[channels] Join: isLocalUser=${isLocalUser}`);
3228
3319
  if (isLocalUser) {
3229
3320
  console.log(`[channels] Calling userBridge.joinChannel(${username}, ${channelId})`);
3230
- success = await userBridge.joinChannel(username, channelId);
3321
+ success = await userBridge?.joinChannel(username, channelId) ?? false;
3231
3322
  console.log(`[channels] userBridge.joinChannel returned: ${success}`);
3232
3323
  }
3233
3324
  // Step 2: If not local or userBridge failed, use relay client fallback
@@ -3264,7 +3355,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3264
3355
  }
3265
3356
  const workspaceId = resolveWorkspaceId(req);
3266
3357
  try {
3267
- const success = await userBridge.leaveChannel(username, channel);
3358
+ const success = await userBridge?.leaveChannel(username, channel);
3268
3359
  if (success) {
3269
3360
  await persistChannelMembershipEvent(channel, username, 'leave', { workspaceId });
3270
3361
  }
@@ -3286,7 +3377,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3286
3377
  const workspaceId = resolveWorkspaceId(req);
3287
3378
  try {
3288
3379
  console.log(`[channels] Admin join: ${member} -> ${channel}`);
3289
- const success = await userBridge.adminJoinChannel(channel, member);
3380
+ const success = await userBridge?.adminJoinChannel(channel, member);
3290
3381
  if (success) {
3291
3382
  await persistChannelMembershipEvent(channel, member, 'join', { workspaceId });
3292
3383
  }
@@ -3329,7 +3420,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3329
3420
  const workspaceId = resolveWorkspaceId(req);
3330
3421
  try {
3331
3422
  console.log(`[channels] Admin remove: ${member} <- ${channel}`);
3332
- const success = await userBridge.adminRemoveMember(channel, member);
3423
+ const success = await userBridge?.adminRemoveMember(channel, member);
3333
3424
  if (success) {
3334
3425
  await persistChannelMembershipEvent(channel, member, 'leave', { workspaceId });
3335
3426
  }
@@ -3368,7 +3459,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3368
3459
  }
3369
3460
  }
3370
3461
  // Get connected users from userBridge
3371
- const connectedUsers = userBridge.getRegisteredUsers();
3462
+ const connectedUsers = userBridge?.getRegisteredUsers() ?? [];
3372
3463
  // Build member list
3373
3464
  const memberSet = new Set();
3374
3465
  // Add persisted members
@@ -3502,7 +3593,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3502
3593
  // This prevents falling back to getRelayClient which creates a conflicting connection
3503
3594
  let regAttempts = 0;
3504
3595
  const maxRegAttempts = 20; // 2 seconds max wait
3505
- while (!userBridge.isUserRegistered(username) && regAttempts < maxRegAttempts) {
3596
+ while (!userBridge?.isUserRegistered(username) && regAttempts < maxRegAttempts) {
3506
3597
  await new Promise(r => setTimeout(r, 100));
3507
3598
  regAttempts++;
3508
3599
  }
@@ -3512,13 +3603,13 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3512
3603
  // Check if user is registered with userBridge (cloud users via presence)
3513
3604
  // This is the preferred path as it uses the existing relay connection
3514
3605
  // and ensures channel messages flow through the proper callback chain
3515
- if (userBridge.isUserRegistered(username)) {
3606
+ if (userBridge?.isUserRegistered(username)) {
3516
3607
  console.log(`[channel-debug] SUBSCRIBE via userBridge for ${username}`);
3517
3608
  for (const channel of channelList) {
3518
3609
  const channelId = channel.startsWith('dm:')
3519
3610
  ? channel
3520
3611
  : (channel.startsWith('#') ? channel : `#${channel}`);
3521
- const joined = await userBridge.joinChannel(username, channelId);
3612
+ const joined = await userBridge?.joinChannel(username, channelId);
3522
3613
  if (joined) {
3523
3614
  joinedChannels.push(channelId);
3524
3615
  }
@@ -3593,16 +3684,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3593
3684
  // For cloud-proxied requests, we need to use relay client directly
3594
3685
  let success = false;
3595
3686
  // Step 1: Check if user is registered with userBridge (local mode)
3596
- const isLocalUser = userBridge.isUserRegistered(username);
3687
+ const isLocalUser = userBridge?.isUserRegistered(username);
3597
3688
  console.log(`[channel-msg] Is local user: ${isLocalUser}`);
3598
3689
  if (isLocalUser) {
3599
3690
  // Local user - use userBridge
3600
3691
  console.log('[channel-msg] Using userBridge (local user)');
3601
- success = await userBridge.sendChannelMessage(username, channelId, body, {
3692
+ success = await userBridge?.sendChannelMessage(username, channelId, body, {
3602
3693
  thread,
3603
3694
  data: workspaceId ? { _workspaceId: workspaceId } : undefined,
3604
3695
  attachments,
3605
- });
3696
+ }) ?? false;
3606
3697
  console.log(`[channel-msg] userBridge result: ${success}`);
3607
3698
  }
3608
3699
  // Step 2: If not local or userBridge failed, use relay client
@@ -3715,7 +3806,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
3715
3806
  return res.status(400).json({ error: 'from, to, and body required' });
3716
3807
  }
3717
3808
  try {
3718
- const success = await userBridge.sendDirectMessage(from, to, body, { thread });
3809
+ const success = await userBridge?.sendDirectMessage(from, to, body, { thread });
3719
3810
  res.json({ success });
3720
3811
  }
3721
3812
  catch (err) {
@@ -4968,9 +5059,24 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4968
5059
  const effectiveCwd = cwd || (spawnerName ? agentCwdMap.get(spawnerName) : undefined);
4969
5060
  try {
4970
5061
  let result;
4971
- if (useExternalSpawnManager) {
5062
+ if (useBrokerAdapter) {
5063
+ // Route spawn through broker SDK → Rust binary
5064
+ result = await relayAdapter.spawn({
5065
+ name,
5066
+ cli,
5067
+ task,
5068
+ team: team || undefined,
5069
+ cwd: effectiveCwd || undefined,
5070
+ interactive,
5071
+ shadowMode,
5072
+ shadowOf,
5073
+ spawnerName: spawnerName || undefined,
5074
+ userId: typeof userId === 'string' ? userId : undefined,
5075
+ includeWorkflowConventions: true,
5076
+ });
5077
+ }
5078
+ else if (useExternalSpawnManager) {
4972
5079
  // Route spawn through SDK → daemon socket → SpawnManager
4973
- // This ensures relay-pty binary resolution works regardless of install method
4974
5080
  const client = await getRelayClient('Dashboard');
4975
5081
  if (!client) {
4976
5082
  return res.status(503).json({
@@ -4978,8 +5084,6 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4978
5084
  error: 'Not connected to relay daemon',
4979
5085
  });
4980
5086
  }
4981
- // spawnerName/userId/includeWorkflowConventions need SDK >= 2.2.0 types
4982
- // Cast removed once SDK is republished with these fields
4983
5087
  result = await client.spawn({
4984
5088
  name,
4985
5089
  cli,
@@ -5236,7 +5340,15 @@ Then output: \`->relay-file:all\`
5236
5340
  Start by greeting the project leads and asking for status updates.`;
5237
5341
  try {
5238
5342
  let result;
5239
- if (useExternalSpawnManager) {
5343
+ if (useBrokerAdapter) {
5344
+ result = await relayAdapter.spawn({
5345
+ name: 'Architect',
5346
+ cli,
5347
+ task: architectPrompt,
5348
+ includeWorkflowConventions: true,
5349
+ });
5350
+ }
5351
+ else if (useExternalSpawnManager) {
5240
5352
  const client = await getRelayClient('Dashboard');
5241
5353
  if (!client) {
5242
5354
  return res.status(503).json({ success: false, error: 'Not connected to relay daemon' });
@@ -5343,7 +5455,7 @@ Start by greeting the project leads and asking for status updates.`;
5343
5455
  * DELETE /api/spawned/:name - Release a spawned agent
5344
5456
  */
5345
5457
  app.delete('/api/spawned/:name', async (req, res) => {
5346
- if (!spawner && !useExternalSpawnManager) {
5458
+ if (!spawner && !useExternalSpawnManager && !useBrokerAdapter) {
5347
5459
  return res.status(503).json({
5348
5460
  success: false,
5349
5461
  error: 'Spawner not enabled',
@@ -5352,8 +5464,11 @@ Start by greeting the project leads and asking for status updates.`;
5352
5464
  const { name } = req.params;
5353
5465
  try {
5354
5466
  let released;
5355
- if (useExternalSpawnManager) {
5356
- // Route release through SDK → daemon socket → SpawnManager
5467
+ if (useBrokerAdapter) {
5468
+ const result = await relayAdapter.release(name);
5469
+ released = result.success;
5470
+ }
5471
+ else if (useExternalSpawnManager) {
5357
5472
  const client = await getRelayClient('Dashboard');
5358
5473
  if (!client) {
5359
5474
  return res.status(503).json({ success: false, error: 'Not connected to relay daemon' });
@@ -5396,7 +5511,7 @@ Start by greeting the project leads and asking for status updates.`;
5396
5511
  * Sends ESC ESC (0x1b 0x1b) to the agent's PTY to interrupt the current operation.
5397
5512
  * This is useful for breaking agents out of stuck loops without terminating them.
5398
5513
  */
5399
- app.post('/api/agents/by-name/:name/interrupt', (req, res) => {
5514
+ app.post('/api/agents/by-name/:name/interrupt', async (req, res) => {
5400
5515
  if (!spawnReader) {
5401
5516
  return res.status(503).json({
5402
5517
  success: false,
@@ -5414,7 +5529,7 @@ Start by greeting the project leads and asking for status updates.`;
5414
5529
  try {
5415
5530
  // Send ESC ESC sequence to interrupt the agent
5416
5531
  // ESC = 0x1b in hexadecimal
5417
- const success = spawnReader.sendWorkerInput(name, '\x1b\x1b');
5532
+ const success = await spawnReader.sendWorkerInput(name, '\x1b\x1b');
5418
5533
  if (success) {
5419
5534
  console.log(`[api] Sent interrupt (ESC ESC) to agent ${name}`);
5420
5535
  res.json({