agent-relay 1.5.1 → 1.6.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 (74) hide show
  1. package/bin/relay-pty +0 -0
  2. package/bin/relay-pty-darwin-arm64 +0 -0
  3. package/bin/relay-pty-darwin-x64 +0 -0
  4. package/bin/relay-pty-linux-x64 +0 -0
  5. package/dist/bridge/spawner.d.ts +19 -0
  6. package/dist/bridge/spawner.js +101 -8
  7. package/dist/cli/index.js +3 -0
  8. package/dist/cloud/api/daemons.js +26 -0
  9. package/dist/cloud/server.js +40 -0
  10. package/dist/cloud/services/cloud-message-bus.d.ts +28 -0
  11. package/dist/cloud/services/cloud-message-bus.js +19 -0
  12. package/dist/cloud/services/index.d.ts +2 -0
  13. package/dist/cloud/services/index.js +4 -0
  14. package/dist/cloud/services/presence-registry.d.ts +56 -0
  15. package/dist/cloud/services/presence-registry.js +91 -0
  16. package/dist/daemon/agent-registry.d.ts +5 -0
  17. package/dist/daemon/agent-registry.js +7 -0
  18. package/dist/daemon/cloud-sync.d.ts +1 -0
  19. package/dist/daemon/cloud-sync.js +8 -0
  20. package/dist/daemon/router.d.ts +44 -0
  21. package/dist/daemon/router.js +192 -1
  22. package/dist/daemon/server.d.ts +16 -0
  23. package/dist/daemon/server.js +34 -1
  24. package/dist/daemon/workspace-manager.d.ts +5 -0
  25. package/dist/daemon/workspace-manager.js +25 -0
  26. package/dist/dashboard/out/404.html +1 -1
  27. package/dist/dashboard/out/_next/static/chunks/64-f4268c2ac6f4d7d4.js +1 -0
  28. package/dist/dashboard/out/app/onboarding.html +1 -1
  29. package/dist/dashboard/out/app/onboarding.txt +1 -1
  30. package/dist/dashboard/out/app.html +1 -1
  31. package/dist/dashboard/out/app.txt +2 -2
  32. package/dist/dashboard/out/cloud/link.html +1 -1
  33. package/dist/dashboard/out/cloud/link.txt +1 -1
  34. package/dist/dashboard/out/connect-repos.html +1 -1
  35. package/dist/dashboard/out/connect-repos.txt +1 -1
  36. package/dist/dashboard/out/history.html +1 -1
  37. package/dist/dashboard/out/history.txt +1 -1
  38. package/dist/dashboard/out/index.html +1 -1
  39. package/dist/dashboard/out/index.txt +2 -2
  40. package/dist/dashboard/out/login.html +1 -1
  41. package/dist/dashboard/out/login.txt +1 -1
  42. package/dist/dashboard/out/metrics.html +1 -1
  43. package/dist/dashboard/out/metrics.txt +1 -1
  44. package/dist/dashboard/out/pricing.html +1 -1
  45. package/dist/dashboard/out/pricing.txt +1 -1
  46. package/dist/dashboard/out/providers/setup/claude.html +1 -1
  47. package/dist/dashboard/out/providers/setup/claude.txt +1 -1
  48. package/dist/dashboard/out/providers/setup/codex.html +1 -1
  49. package/dist/dashboard/out/providers/setup/codex.txt +1 -1
  50. package/dist/dashboard/out/providers.html +1 -1
  51. package/dist/dashboard/out/providers.txt +1 -1
  52. package/dist/dashboard/out/signup.html +1 -1
  53. package/dist/dashboard/out/signup.txt +1 -1
  54. package/dist/dashboard-server/server.d.ts +10 -0
  55. package/dist/dashboard-server/server.js +36 -8
  56. package/dist/dashboard-server/user-bridge.js +9 -1
  57. package/dist/utils/agent-config.d.ts +2 -0
  58. package/dist/utils/agent-config.js +1 -0
  59. package/dist/wrapper/base-wrapper.d.ts +19 -1
  60. package/dist/wrapper/base-wrapper.js +40 -2
  61. package/dist/wrapper/prompt-composer.d.ts +67 -0
  62. package/dist/wrapper/prompt-composer.js +168 -0
  63. package/dist/wrapper/pty-wrapper.js +3 -0
  64. package/dist/wrapper/relay-pty-orchestrator.d.ts +28 -2
  65. package/dist/wrapper/relay-pty-orchestrator.js +131 -13
  66. package/dist/wrapper/shared.d.ts +3 -0
  67. package/dist/wrapper/shared.js +13 -2
  68. package/dist/wrapper/stuck-detector.d.ts +101 -0
  69. package/dist/wrapper/stuck-detector.js +228 -0
  70. package/dist/wrapper/tmux-wrapper.js +2 -0
  71. package/package.json +2 -1
  72. package/dist/dashboard/out/_next/static/chunks/64-2cf6a4c4286af350.js +0 -1
  73. /package/dist/dashboard/out/_next/static/{c6Ndf9KrCr5HE-vSoIyj6 → BffXAqxm-_rUlj2mAnK26}/_buildManifest.js +0 -0
  74. /package/dist/dashboard/out/_next/static/{c6Ndf9KrCr5HE-vSoIyj6 → BffXAqxm-_rUlj2mAnK26}/_ssgManifest.js +0 -0
package/bin/relay-pty CHANGED
Binary file
Binary file
Binary file
Binary file
@@ -36,6 +36,22 @@ export type OnAgentDeathCallback = (info: {
36
36
  agentId?: string;
37
37
  resumeInstructions?: string;
38
38
  }) => void;
39
+ /** Options for AgentSpawner constructor */
40
+ export interface AgentSpawnerOptions {
41
+ projectRoot: string;
42
+ tmuxSession?: string;
43
+ dashboardPort?: number;
44
+ /**
45
+ * Callback to mark an agent as spawning (before HELLO completes).
46
+ * Messages sent to this agent will be queued for delivery after registration.
47
+ */
48
+ onMarkSpawning?: (agentName: string) => void;
49
+ /**
50
+ * Callback to clear the spawning flag for an agent.
51
+ * Called when spawn fails or is cancelled.
52
+ */
53
+ onClearSpawning?: (agentName: string) => void;
54
+ }
39
55
  export declare class AgentSpawner {
40
56
  private activeWorkers;
41
57
  private agentsPath;
@@ -48,7 +64,10 @@ export declare class AgentSpawner {
48
64
  private cloudPersistence?;
49
65
  private policyService?;
50
66
  private policyEnforcementEnabled;
67
+ private onMarkSpawning?;
68
+ private onClearSpawning?;
51
69
  constructor(projectRoot: string, _tmuxSession?: string, dashboardPort?: number);
70
+ constructor(options: AgentSpawnerOptions);
52
71
  /**
53
72
  * Set cloud policy fetcher for workspace-level policies
54
73
  */
@@ -16,7 +16,8 @@ import { selectShadowCli } from './shadow-cli.js';
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = path.dirname(__filename);
18
18
  import { AgentPolicyService } from '../policy/agent-policy.js';
19
- import { buildClaudeArgs } from '../utils/agent-config.js';
19
+ import { buildClaudeArgs, findAgentConfig } from '../utils/agent-config.js';
20
+ import { composeForAgent } from '../wrapper/prompt-composer.js';
20
21
  import { getUserDirectoryService } from '../daemon/user-directory.js';
21
22
  /**
22
23
  * Get relay protocol instructions for a spawned agent.
@@ -144,8 +145,14 @@ export class AgentSpawner {
144
145
  cloudPersistence;
145
146
  policyService;
146
147
  policyEnforcementEnabled = false;
147
- constructor(projectRoot, _tmuxSession, dashboardPort) {
148
- const paths = getProjectPaths(projectRoot);
148
+ onMarkSpawning;
149
+ onClearSpawning;
150
+ constructor(projectRootOrOptions, _tmuxSession, dashboardPort) {
151
+ // Handle both old and new constructor signatures
152
+ const options = typeof projectRootOrOptions === 'string'
153
+ ? { projectRoot: projectRootOrOptions, tmuxSession: _tmuxSession, dashboardPort }
154
+ : projectRootOrOptions;
155
+ const paths = getProjectPaths(options.projectRoot);
149
156
  this.projectRoot = paths.projectRoot;
150
157
  // Use connected-agents.json (live socket connections) instead of agents.json (historical registry)
151
158
  // This ensures spawned agents have actual daemon connections for channel message delivery
@@ -153,7 +160,10 @@ export class AgentSpawner {
153
160
  this.socketPath = paths.socketPath;
154
161
  this.logsDir = path.join(paths.teamDir, 'worker-logs');
155
162
  this.workersPath = path.join(paths.teamDir, 'workers.json');
156
- this.dashboardPort = dashboardPort;
163
+ this.dashboardPort = options.dashboardPort;
164
+ // Store spawn tracking callbacks
165
+ this.onMarkSpawning = options.onMarkSpawning;
166
+ this.onClearSpawning = options.onClearSpawning;
157
167
  // Ensure logs directory exists
158
168
  fs.mkdirSync(this.logsDir, { recursive: true });
159
169
  // Initialize policy service if enforcement is enabled
@@ -306,10 +316,15 @@ export class AgentSpawner {
306
316
  // Apply agent config (model, --agent flag) from .claude/agents/ if available
307
317
  // This ensures spawned agents respect their profile settings
308
318
  if (isClaudeCli) {
319
+ // Get agent config for model tracking
320
+ const agentConfig = findAgentConfig(name, this.projectRoot);
321
+ const model = agentConfig?.model || 'sonnet'; // Default to sonnet
309
322
  const configuredArgs = buildClaudeArgs(name, args, this.projectRoot);
310
323
  // Replace args with configured version (includes --model and --agent if found)
311
324
  args.length = 0;
312
325
  args.push(...configuredArgs);
326
+ // Cost tracking: log which model is being used
327
+ console.log(`[spawner] Agent ${name}: model=${model}, cli=${cli}`);
313
328
  if (debug)
314
329
  console.log(`[spawner:debug] Applied agent config for ${name}: ${args.join(' ')}`);
315
330
  }
@@ -319,13 +334,38 @@ export class AgentSpawner {
319
334
  args.push('--dangerously-bypass-approvals-and-sandbox');
320
335
  }
321
336
  // Inject relay protocol instructions via CLI-specific system prompt
322
- const relayInstructions = getRelayInstructions(name);
337
+ let relayInstructions = getRelayInstructions(name);
338
+ // Compose role-specific prompts if agent has a role defined in .claude/agents/
339
+ const agentConfigForRole = isClaudeCli ? findAgentConfig(name, this.projectRoot) : null;
340
+ if (agentConfigForRole?.role) {
341
+ const validRoles = ['planner', 'worker', 'reviewer', 'lead', 'shadow'];
342
+ const role = agentConfigForRole.role.toLowerCase();
343
+ if (validRoles.includes(role)) {
344
+ try {
345
+ const composed = await composeForAgent({ name, role }, this.projectRoot, { taskDescription: task });
346
+ if (composed.content) {
347
+ relayInstructions = `${composed.content}\n\n---\n\n${relayInstructions}`;
348
+ if (debug)
349
+ console.log(`[spawner:debug] Composed role prompt for ${name} (role: ${role})`);
350
+ }
351
+ }
352
+ catch (err) {
353
+ console.warn(`[spawner] Failed to compose role prompt for ${name}: ${err.message}`);
354
+ }
355
+ }
356
+ }
323
357
  if (isClaudeCli && !args.includes('--append-system-prompt')) {
324
358
  args.push('--append-system-prompt', relayInstructions);
325
359
  }
326
360
  else if (isCodexCli && !args.some(a => a.includes('developer_instructions'))) {
327
361
  args.push('--config', `developer_instructions=${relayInstructions}`);
328
362
  }
363
+ // Codex requires an initial prompt in TTY mode (unlike Claude which waits for input)
364
+ // Pass the task as the initial prompt, or a generic "ready" message if no task
365
+ if (isCodexCli) {
366
+ const initialPrompt = task || 'You are ready. Wait for messages from the relay system.';
367
+ args.push(initialPrompt);
368
+ }
329
369
  if (debug)
330
370
  console.log(`[spawner:debug] Spawning ${name} with: ${command} ${args.join(' ')}`);
331
371
  // Create PtyWrapper config
@@ -463,6 +503,13 @@ export class AgentSpawner {
463
503
  listeners.summary = cloudListeners.summary;
464
504
  if (cloudListeners.sessionEnd)
465
505
  listeners.sessionEnd = cloudListeners.sessionEnd;
506
+ // Mark agent as spawning BEFORE starting PTY
507
+ // This allows messages sent to this agent to be queued until HELLO completes
508
+ if (this.onMarkSpawning) {
509
+ this.onMarkSpawning(name);
510
+ if (debug)
511
+ console.log(`[spawner:debug] Marked ${name} as spawning`);
512
+ }
466
513
  await pty.start();
467
514
  if (debug)
468
515
  console.log(`[spawner:debug] PTY started, pid: ${pty.pid}`);
@@ -471,6 +518,10 @@ export class AgentSpawner {
471
518
  if (!registered) {
472
519
  const error = `Worker ${name} failed to register within 30s`;
473
520
  console.error(`[spawner] ${error}`);
521
+ // Clear spawning flag since spawn failed
522
+ if (this.onClearSpawning) {
523
+ this.onClearSpawning(name);
524
+ }
474
525
  await pty.kill();
475
526
  return {
476
527
  success: false,
@@ -478,9 +529,47 @@ export class AgentSpawner {
478
529
  error,
479
530
  };
480
531
  }
481
- // Note: Task is NOT sent here. The spawning agent (wrapper) waits for the worker
482
- // to come online and then sends the task via normal relay message.
483
- // This avoids race conditions with the agent's readyForMessages state.
532
+ // Send task to the newly spawned agent if provided
533
+ // We do this AFTER registration AND after the CLI is ready to receive input
534
+ if (task && task.trim() && this.dashboardPort) {
535
+ try {
536
+ // Wait for the CLI to be ready (has produced output AND is idle)
537
+ // This is more reliable than a random sleep because it waits for actual signals
538
+ if (useRelayPty && 'waitUntilCliReady' in pty) {
539
+ const orchestrator = pty;
540
+ const ready = await orchestrator.waitUntilCliReady(15000, 100);
541
+ if (!ready) {
542
+ console.warn(`[spawner] CLI for ${name} did not become ready within timeout, sending task anyway`);
543
+ }
544
+ else if (debug) {
545
+ console.log(`[spawner:debug] CLI for ${name} is ready to receive messages`);
546
+ }
547
+ }
548
+ else {
549
+ // PtyWrapper fallback - use short delay as it doesn't have waitUntilCliReady
550
+ await sleep(500);
551
+ }
552
+ const sendResponse = await fetch(`http://localhost:${this.dashboardPort}/api/send`, {
553
+ method: 'POST',
554
+ headers: { 'Content-Type': 'application/json' },
555
+ body: JSON.stringify({
556
+ to: name,
557
+ message: task,
558
+ from: spawnerName, // Include spawner name so message appears from correct agent
559
+ }),
560
+ });
561
+ if (sendResponse.ok) {
562
+ if (debug)
563
+ console.log(`[spawner:debug] Task sent to ${name}`);
564
+ }
565
+ else {
566
+ console.error(`[spawner] Failed to send task to ${name}: ${sendResponse.status}`);
567
+ }
568
+ }
569
+ catch (err) {
570
+ console.error(`[spawner] Error sending task to ${name}:`, err.message);
571
+ }
572
+ }
484
573
  // Track the worker
485
574
  const workerInfo = {
486
575
  name,
@@ -509,6 +598,10 @@ export class AgentSpawner {
509
598
  console.error(`[spawner] Failed to spawn ${name}:`, err.message);
510
599
  if (debug)
511
600
  console.error(`[spawner:debug] Full error:`, err);
601
+ // Clear spawning flag since spawn failed
602
+ if (this.onClearSpawning) {
603
+ this.onClearSpawning(name);
604
+ }
512
605
  return {
513
606
  success: false,
514
607
  name,
package/dist/cli/index.js CHANGED
@@ -384,6 +384,9 @@ program
384
384
  dbPath,
385
385
  enableSpawner: true,
386
386
  projectRoot: paths.projectRoot,
387
+ // Pass spawn tracking callbacks so messages can be queued before HELLO completes
388
+ onMarkSpawning: (name) => daemon.markSpawning(name),
389
+ onClearSpawning: (name) => daemon.clearSpawning(name),
387
390
  });
388
391
  console.log(`Dashboard: http://localhost:${dashboardPort}`);
389
392
  // Hook daemon log output to dashboard WebSocket
@@ -12,6 +12,8 @@ import { Router } from 'express';
12
12
  import { randomBytes, createHash } from 'crypto';
13
13
  import { requireAuth } from './auth.js';
14
14
  import { db } from '../db/index.js';
15
+ import { getOnlineUsersForDiscovery, isUserOnline } from '../services/presence-registry.js';
16
+ import { cloudMessageBus } from '../services/cloud-message-bus.js';
15
17
  export const daemonsRouter = Router();
16
18
  /**
17
19
  * Generate a secure API key
@@ -330,9 +332,12 @@ daemonsRouter.post('/agents', requireDaemonAuth, async (req, res) => {
330
332
  machineId: d.machineId,
331
333
  }));
332
334
  });
335
+ // Get online users from presence registry (for cross-machine user routing)
336
+ const allUsers = getOnlineUsersForDiscovery();
333
337
  res.json({
334
338
  success: true,
335
339
  allAgents, // Return all agents across all linked daemons
340
+ allUsers, // Return online users for cross-machine routing
336
341
  });
337
342
  }
338
343
  catch (error) {
@@ -351,6 +356,27 @@ daemonsRouter.post('/message', requireDaemonAuth, async (req, res) => {
351
356
  return res.status(400).json({ error: 'targetDaemonId, targetAgent, and message are required' });
352
357
  }
353
358
  try {
359
+ // Special case: messages to cloud users (daemonId = 'cloud')
360
+ if (targetDaemonId === 'cloud') {
361
+ // Verify user is online
362
+ if (!isUserOnline(targetAgent)) {
363
+ return res.status(404).json({ error: 'User not online' });
364
+ }
365
+ // Send via cloud message bus for WebSocket delivery
366
+ cloudMessageBus.sendToUser(targetAgent, {
367
+ from: {
368
+ daemonId: daemon.id,
369
+ daemonName: daemon.name,
370
+ agent: message.from,
371
+ },
372
+ to: targetAgent,
373
+ body: message.content,
374
+ timestamp: new Date().toISOString(),
375
+ metadata: message.metadata,
376
+ });
377
+ console.log(`[daemons] Message sent to cloud user ${targetAgent} from ${message.from}`);
378
+ return res.json({ success: true, message: 'Message sent to cloud user' });
379
+ }
354
380
  // Verify target daemon belongs to same user
355
381
  const targetDaemon = await db.linkedDaemons.findById(targetDaemonId);
356
382
  if (!targetDaemon || targetDaemon.userId !== daemon.userId) {
@@ -40,6 +40,8 @@ import { adminRouter } from './api/admin.js';
40
40
  import { consensusRouter } from './api/consensus.js';
41
41
  import { db } from './db/index.js';
42
42
  import { validateSshSecurityConfig } from './services/ssh-security.js';
43
+ import { registerUserPresence, unregisterUserPresence, updateUserLastSeen } from './services/presence-registry.js';
44
+ import { cloudMessageBus } from './services/cloud-message-bus.js';
43
45
  /**
44
46
  * Proxy a request to the user's primary running workspace
45
47
  */
@@ -1337,6 +1339,8 @@ export async function createServer() {
1337
1339
  if (existing) {
1338
1340
  existing.connections.add(ws);
1339
1341
  existing.info.lastSeen = now;
1342
+ // Update last seen in shared presence registry
1343
+ updateUserLastSeen(username);
1340
1344
  // Only log at milestones to reduce noise
1341
1345
  const count = existing.connections.size;
1342
1346
  if (count === 2 || count === 5 || count === 10 || count % 50 === 0) {
@@ -1348,6 +1352,8 @@ export async function createServer() {
1348
1352
  info: { username, avatarUrl, connectedAt: now, lastSeen: now },
1349
1353
  connections: new Set([ws]),
1350
1354
  });
1355
+ // Register with shared presence registry for cross-module access
1356
+ registerUserPresence({ username, avatarUrl, connectedAt: now, lastSeen: now });
1351
1357
  console.log(`[cloud] User ${username} came online`);
1352
1358
  broadcastPresence({
1353
1359
  type: 'presence_join',
@@ -1367,6 +1373,8 @@ export async function createServer() {
1367
1373
  userState.connections.delete(ws);
1368
1374
  if (userState.connections.size === 0) {
1369
1375
  onlineUsers.delete(clientUsername);
1376
+ // Unregister from shared presence registry
1377
+ unregisterUserPresence(clientUsername);
1370
1378
  console.log(`[cloud] User ${clientUsername} went offline`);
1371
1379
  broadcastPresence({ type: 'presence_leave', username: clientUsername });
1372
1380
  }
@@ -1379,6 +1387,8 @@ export async function createServer() {
1379
1387
  const userState = onlineUsers.get(clientUsername);
1380
1388
  if (userState) {
1381
1389
  userState.info.lastSeen = new Date().toISOString();
1390
+ // Update last seen in shared presence registry
1391
+ updateUserLastSeen(clientUsername);
1382
1392
  }
1383
1393
  broadcastPresence({
1384
1394
  type: 'typing',
@@ -1430,6 +1440,8 @@ export async function createServer() {
1430
1440
  userState.connections.delete(ws);
1431
1441
  if (userState.connections.size === 0) {
1432
1442
  onlineUsers.delete(clientUsername);
1443
+ // Unregister from shared presence registry
1444
+ unregisterUserPresence(clientUsername);
1433
1445
  console.log(`[cloud] User ${clientUsername} disconnected`);
1434
1446
  broadcastPresence({ type: 'presence_leave', username: clientUsername });
1435
1447
  }
@@ -1443,6 +1455,34 @@ export async function createServer() {
1443
1455
  wssPresence.on('error', (err) => {
1444
1456
  console.error('[cloud] Presence WebSocket server error:', err);
1445
1457
  });
1458
+ // Subscribe to cloud message bus for delivering messages to cloud users
1459
+ cloudMessageBus.on('user-message', ({ username, message }) => {
1460
+ const userState = onlineUsers.get(username);
1461
+ if (!userState) {
1462
+ console.warn(`[cloud] Cannot deliver message to ${username}: user not online`);
1463
+ return;
1464
+ }
1465
+ // Deliver to all of the user's WebSocket connections
1466
+ const payload = JSON.stringify({
1467
+ type: 'direct_message',
1468
+ from: message.from.agent,
1469
+ body: message.body,
1470
+ timestamp: message.timestamp,
1471
+ metadata: {
1472
+ ...message.metadata,
1473
+ daemonId: message.from.daemonId,
1474
+ daemonName: message.from.daemonName,
1475
+ },
1476
+ });
1477
+ let delivered = 0;
1478
+ userState.connections.forEach((ws) => {
1479
+ if (ws.readyState === WebSocket.OPEN) {
1480
+ ws.send(payload);
1481
+ delivered++;
1482
+ }
1483
+ });
1484
+ console.log(`[cloud] Delivered message to ${username} (${delivered} connections)`);
1485
+ });
1446
1486
  return {
1447
1487
  app,
1448
1488
  async start() {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Cloud Message Bus - Event-based message delivery for cloud users.
3
+ *
4
+ * This module provides a simple pub/sub mechanism for delivering messages
5
+ * to users connected via the cloud dashboard. The daemons API publishes
6
+ * messages here, and the presence WebSocket handler subscribes to deliver them.
7
+ */
8
+ import { EventEmitter } from 'events';
9
+ export interface CloudMessage {
10
+ from: {
11
+ daemonId: string;
12
+ daemonName: string;
13
+ agent: string;
14
+ };
15
+ to: string;
16
+ body: string;
17
+ timestamp: string;
18
+ metadata?: Record<string, unknown>;
19
+ }
20
+ declare class CloudMessageBus extends EventEmitter {
21
+ /**
22
+ * Send a message to a cloud user
23
+ */
24
+ sendToUser(username: string, message: CloudMessage): void;
25
+ }
26
+ export declare const cloudMessageBus: CloudMessageBus;
27
+ export {};
28
+ //# sourceMappingURL=cloud-message-bus.d.ts.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Cloud Message Bus - Event-based message delivery for cloud users.
3
+ *
4
+ * This module provides a simple pub/sub mechanism for delivering messages
5
+ * to users connected via the cloud dashboard. The daemons API publishes
6
+ * messages here, and the presence WebSocket handler subscribes to deliver them.
7
+ */
8
+ import { EventEmitter } from 'events';
9
+ class CloudMessageBus extends EventEmitter {
10
+ /**
11
+ * Send a message to a cloud user
12
+ */
13
+ sendToUser(username, message) {
14
+ this.emit('user-message', { username, message });
15
+ }
16
+ }
17
+ // Singleton instance
18
+ export const cloudMessageBus = new CloudMessageBus();
19
+ //# sourceMappingURL=cloud-message-bus.js.map
@@ -12,4 +12,6 @@ export { handleMention, handleIssueAssignment, getPendingMentions, getPendingIss
12
12
  export { ComputeEnforcementService, ComputeEnforcementConfig, EnforcementResult, getComputeEnforcementService, createComputeEnforcementService, } from './compute-enforcement.js';
13
13
  export { IntroExpirationService, IntroExpirationConfig, IntroStatus, ExpirationResult as IntroExpirationResult, INTRO_PERIOD_DAYS, getIntroStatus, getIntroExpirationService, startIntroExpirationService, stopIntroExpirationService, } from './intro-expiration.js';
14
14
  export { WorkspaceKeepaliveService, WorkspaceKeepaliveConfig, KeepaliveStats, getWorkspaceKeepaliveService, createWorkspaceKeepaliveService, } from './workspace-keepalive.js';
15
+ export { registerUserPresence, unregisterUserPresence, updateUserLastSeen, isUserOnline, getOnlineUser, getOnlineUsers, getOnlineUsersForDiscovery, clearAllPresence, type PresenceUserInfo, } from './presence-registry.js';
16
+ export { cloudMessageBus, type CloudMessage, } from './cloud-message-bus.js';
15
17
  //# sourceMappingURL=index.d.ts.map
@@ -18,4 +18,8 @@ export { ComputeEnforcementService, getComputeEnforcementService, createComputeE
18
18
  export { IntroExpirationService, INTRO_PERIOD_DAYS, getIntroStatus, getIntroExpirationService, startIntroExpirationService, stopIntroExpirationService, } from './intro-expiration.js';
19
19
  // Workspace keepalive (prevent Fly.io from idling machines with active agents)
20
20
  export { WorkspaceKeepaliveService, getWorkspaceKeepaliveService, createWorkspaceKeepaliveService, } from './workspace-keepalive.js';
21
+ // Presence registry (shared registry for tracking online users)
22
+ export { registerUserPresence, unregisterUserPresence, updateUserLastSeen, isUserOnline, getOnlineUser, getOnlineUsers, getOnlineUsersForDiscovery, clearAllPresence, } from './presence-registry.js';
23
+ // Cloud message bus (event-based message delivery for cloud users)
24
+ export { cloudMessageBus, } from './cloud-message-bus.js';
21
25
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Presence Registry - Shared registry for tracking online users across cloud server.
3
+ *
4
+ * This singleton module allows both the WebSocket handler in server.ts and the
5
+ * daemons API routes to access the current list of online users.
6
+ */
7
+ export interface PresenceUserInfo {
8
+ username: string;
9
+ avatarUrl?: string;
10
+ connectedAt: string;
11
+ lastSeen: string;
12
+ /** Optional workspace context for the user */
13
+ workspaceId?: string;
14
+ }
15
+ /**
16
+ * Register a user connection
17
+ */
18
+ export declare function registerUserPresence(info: PresenceUserInfo): void;
19
+ /**
20
+ * Update last seen time for a user
21
+ */
22
+ export declare function updateUserLastSeen(username: string): void;
23
+ /**
24
+ * Unregister a user connection
25
+ */
26
+ export declare function unregisterUserPresence(username: string): void;
27
+ /**
28
+ * Check if a user is online
29
+ */
30
+ export declare function isUserOnline(username: string): boolean;
31
+ /**
32
+ * Get info for a specific online user
33
+ */
34
+ export declare function getOnlineUser(username: string): PresenceUserInfo | undefined;
35
+ /**
36
+ * Get list of all online users
37
+ */
38
+ export declare function getOnlineUsers(): PresenceUserInfo[];
39
+ /**
40
+ * Get online users formatted for remote agent discovery.
41
+ * Returns in the same format as RemoteAgent so daemons can route to users.
42
+ */
43
+ export declare function getOnlineUsersForDiscovery(): Array<{
44
+ name: string;
45
+ status: string;
46
+ daemonId: string;
47
+ daemonName: string;
48
+ machineId: string;
49
+ isHuman: boolean;
50
+ avatarUrl?: string;
51
+ }>;
52
+ /**
53
+ * Clear all presence (for testing or shutdown)
54
+ */
55
+ export declare function clearAllPresence(): void;
56
+ //# sourceMappingURL=presence-registry.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Presence Registry - Shared registry for tracking online users across cloud server.
3
+ *
4
+ * This singleton module allows both the WebSocket handler in server.ts and the
5
+ * daemons API routes to access the current list of online users.
6
+ */
7
+ /**
8
+ * In-memory registry of online users.
9
+ * Key: username
10
+ * Value: presence state with connection count
11
+ */
12
+ const onlineUsers = new Map();
13
+ /**
14
+ * Register a user connection
15
+ */
16
+ export function registerUserPresence(info) {
17
+ const existing = onlineUsers.get(info.username);
18
+ if (existing) {
19
+ // Update info and increment connection count
20
+ existing.info = { ...existing.info, ...info, lastSeen: new Date().toISOString() };
21
+ existing.connectionCount++;
22
+ }
23
+ else {
24
+ onlineUsers.set(info.username, {
25
+ info: { ...info, lastSeen: new Date().toISOString() },
26
+ connectionCount: 1,
27
+ });
28
+ }
29
+ }
30
+ /**
31
+ * Update last seen time for a user
32
+ */
33
+ export function updateUserLastSeen(username) {
34
+ const state = onlineUsers.get(username);
35
+ if (state) {
36
+ state.info.lastSeen = new Date().toISOString();
37
+ }
38
+ }
39
+ /**
40
+ * Unregister a user connection
41
+ */
42
+ export function unregisterUserPresence(username) {
43
+ const state = onlineUsers.get(username);
44
+ if (state) {
45
+ state.connectionCount--;
46
+ if (state.connectionCount <= 0) {
47
+ onlineUsers.delete(username);
48
+ }
49
+ }
50
+ }
51
+ /**
52
+ * Check if a user is online
53
+ */
54
+ export function isUserOnline(username) {
55
+ return onlineUsers.has(username);
56
+ }
57
+ /**
58
+ * Get info for a specific online user
59
+ */
60
+ export function getOnlineUser(username) {
61
+ return onlineUsers.get(username)?.info;
62
+ }
63
+ /**
64
+ * Get list of all online users
65
+ */
66
+ export function getOnlineUsers() {
67
+ return Array.from(onlineUsers.values()).map((state) => state.info);
68
+ }
69
+ /**
70
+ * Get online users formatted for remote agent discovery.
71
+ * Returns in the same format as RemoteAgent so daemons can route to users.
72
+ */
73
+ export function getOnlineUsersForDiscovery() {
74
+ return getOnlineUsers().map((user) => ({
75
+ name: user.username,
76
+ status: 'online',
77
+ // Use special "cloud" identifier so daemon knows to route via cloud
78
+ daemonId: 'cloud',
79
+ daemonName: 'Cloud Dashboard',
80
+ machineId: 'cloud',
81
+ isHuman: true,
82
+ avatarUrl: user.avatarUrl,
83
+ }));
84
+ }
85
+ /**
86
+ * Clear all presence (for testing or shutdown)
87
+ */
88
+ export function clearAllPresence() {
89
+ onlineUsers.clear();
90
+ }
91
+ //# sourceMappingURL=presence-registry.js.map
@@ -75,6 +75,11 @@ export declare class AgentRegistry {
75
75
  * Get a snapshot of all agents.
76
76
  */
77
77
  getAgents(): AgentRecord[];
78
+ /**
79
+ * Check if an agent exists in the registry (has connected before).
80
+ * Used by the router to determine if messages should be queued for offline agents.
81
+ */
82
+ has(agentName: string): boolean;
78
83
  /**
79
84
  * Remove an agent from the registry.
80
85
  */
@@ -101,6 +101,13 @@ export class AgentRegistry {
101
101
  getAgents() {
102
102
  return Array.from(this.agents.values());
103
103
  }
104
+ /**
105
+ * Check if an agent exists in the registry (has connected before).
106
+ * Used by the router to determine if messages should be queued for offline agents.
107
+ */
108
+ has(agentName) {
109
+ return this.agents.has(agentName);
110
+ }
104
111
  /**
105
112
  * Remove an agent from the registry.
106
113
  */
@@ -50,6 +50,7 @@ export declare class CloudSyncService extends EventEmitter {
50
50
  private machineId;
51
51
  private localAgents;
52
52
  private remoteAgents;
53
+ private remoteUsers;
53
54
  private connected;
54
55
  private storage;
55
56
  private lastMessageSyncTs;
@@ -22,6 +22,7 @@ export class CloudSyncService extends EventEmitter {
22
22
  machineId;
23
23
  localAgents = new Map();
24
24
  remoteAgents = [];
25
+ remoteUsers = [];
25
26
  connected = false;
26
27
  storage = null;
27
28
  lastMessageSyncTs = 0;
@@ -246,6 +247,13 @@ export class CloudSyncService extends EventEmitter {
246
247
  if (this.remoteAgents.length > 0) {
247
248
  this.emit('remote-agents-updated', this.remoteAgents);
248
249
  }
250
+ // Handle remote users (humans connected via cloud dashboard)
251
+ if (data.allUsers) {
252
+ this.remoteUsers = data.allUsers.filter((u) => !this.localAgents.has(u.name));
253
+ if (this.remoteUsers.length > 0) {
254
+ this.emit('remote-users-updated', this.remoteUsers);
255
+ }
256
+ }
249
257
  }
250
258
  /**
251
259
  * Fetch queued messages from cloud