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.
- package/bin/relay-pty +0 -0
- package/bin/relay-pty-darwin-arm64 +0 -0
- package/bin/relay-pty-darwin-x64 +0 -0
- package/bin/relay-pty-linux-x64 +0 -0
- package/dist/bridge/spawner.d.ts +19 -0
- package/dist/bridge/spawner.js +101 -8
- package/dist/cli/index.js +3 -0
- package/dist/cloud/api/daemons.js +26 -0
- package/dist/cloud/server.js +40 -0
- package/dist/cloud/services/cloud-message-bus.d.ts +28 -0
- package/dist/cloud/services/cloud-message-bus.js +19 -0
- package/dist/cloud/services/index.d.ts +2 -0
- package/dist/cloud/services/index.js +4 -0
- package/dist/cloud/services/presence-registry.d.ts +56 -0
- package/dist/cloud/services/presence-registry.js +91 -0
- package/dist/daemon/agent-registry.d.ts +5 -0
- package/dist/daemon/agent-registry.js +7 -0
- package/dist/daemon/cloud-sync.d.ts +1 -0
- package/dist/daemon/cloud-sync.js +8 -0
- package/dist/daemon/router.d.ts +44 -0
- package/dist/daemon/router.js +192 -1
- package/dist/daemon/server.d.ts +16 -0
- package/dist/daemon/server.js +34 -1
- package/dist/daemon/workspace-manager.d.ts +5 -0
- package/dist/daemon/workspace-manager.js +25 -0
- package/dist/dashboard/out/404.html +1 -1
- package/dist/dashboard/out/_next/static/chunks/64-f4268c2ac6f4d7d4.js +1 -0
- package/dist/dashboard/out/app/onboarding.html +1 -1
- package/dist/dashboard/out/app/onboarding.txt +1 -1
- package/dist/dashboard/out/app.html +1 -1
- package/dist/dashboard/out/app.txt +2 -2
- package/dist/dashboard/out/cloud/link.html +1 -1
- package/dist/dashboard/out/cloud/link.txt +1 -1
- package/dist/dashboard/out/connect-repos.html +1 -1
- package/dist/dashboard/out/connect-repos.txt +1 -1
- package/dist/dashboard/out/history.html +1 -1
- package/dist/dashboard/out/history.txt +1 -1
- package/dist/dashboard/out/index.html +1 -1
- package/dist/dashboard/out/index.txt +2 -2
- package/dist/dashboard/out/login.html +1 -1
- package/dist/dashboard/out/login.txt +1 -1
- package/dist/dashboard/out/metrics.html +1 -1
- package/dist/dashboard/out/metrics.txt +1 -1
- package/dist/dashboard/out/pricing.html +1 -1
- package/dist/dashboard/out/pricing.txt +1 -1
- package/dist/dashboard/out/providers/setup/claude.html +1 -1
- package/dist/dashboard/out/providers/setup/claude.txt +1 -1
- package/dist/dashboard/out/providers/setup/codex.html +1 -1
- package/dist/dashboard/out/providers/setup/codex.txt +1 -1
- package/dist/dashboard/out/providers.html +1 -1
- package/dist/dashboard/out/providers.txt +1 -1
- package/dist/dashboard/out/signup.html +1 -1
- package/dist/dashboard/out/signup.txt +1 -1
- package/dist/dashboard-server/server.d.ts +10 -0
- package/dist/dashboard-server/server.js +36 -8
- package/dist/dashboard-server/user-bridge.js +9 -1
- package/dist/utils/agent-config.d.ts +2 -0
- package/dist/utils/agent-config.js +1 -0
- package/dist/wrapper/base-wrapper.d.ts +19 -1
- package/dist/wrapper/base-wrapper.js +40 -2
- package/dist/wrapper/prompt-composer.d.ts +67 -0
- package/dist/wrapper/prompt-composer.js +168 -0
- package/dist/wrapper/pty-wrapper.js +3 -0
- package/dist/wrapper/relay-pty-orchestrator.d.ts +28 -2
- package/dist/wrapper/relay-pty-orchestrator.js +131 -13
- package/dist/wrapper/shared.d.ts +3 -0
- package/dist/wrapper/shared.js +13 -2
- package/dist/wrapper/stuck-detector.d.ts +101 -0
- package/dist/wrapper/stuck-detector.js +228 -0
- package/dist/wrapper/tmux-wrapper.js +2 -0
- package/package.json +2 -1
- package/dist/dashboard/out/_next/static/chunks/64-2cf6a4c4286af350.js +0 -1
- /package/dist/dashboard/out/_next/static/{c6Ndf9KrCr5HE-vSoIyj6 → BffXAqxm-_rUlj2mAnK26}/_buildManifest.js +0 -0
- /package/dist/dashboard/out/_next/static/{c6Ndf9KrCr5HE-vSoIyj6 → BffXAqxm-_rUlj2mAnK26}/_ssgManifest.js +0 -0
package/bin/relay-pty
CHANGED
|
Binary file
|
|
Binary file
|
package/bin/relay-pty-darwin-x64
CHANGED
|
Binary file
|
package/bin/relay-pty-linux-x64
CHANGED
|
Binary file
|
package/dist/bridge/spawner.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/bridge/spawner.js
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
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) {
|
package/dist/cloud/server.js
CHANGED
|
@@ -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
|
*/
|
|
@@ -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
|