agent-relay 1.0.21 → 1.1.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/dist/bridge/shadow-cli.d.ts +17 -0
- package/dist/bridge/shadow-cli.d.ts.map +1 -0
- package/dist/bridge/shadow-cli.js +75 -0
- package/dist/bridge/shadow-cli.js.map +1 -0
- package/dist/bridge/shadow-config.d.ts +87 -0
- package/dist/bridge/shadow-config.d.ts.map +1 -0
- package/dist/bridge/shadow-config.js +134 -0
- package/dist/bridge/shadow-config.js.map +1 -0
- package/dist/bridge/spawner.d.ts +15 -1
- package/dist/bridge/spawner.d.ts.map +1 -1
- package/dist/bridge/spawner.js +164 -4
- package/dist/bridge/spawner.js.map +1 -1
- package/dist/bridge/types.d.ts +55 -0
- package/dist/bridge/types.d.ts.map +1 -1
- package/dist/cli/index.js +796 -11
- package/dist/cli/index.js.map +1 -1
- package/dist/cloud/api/auth.d.ts +19 -0
- package/dist/cloud/api/auth.d.ts.map +1 -0
- package/dist/cloud/api/auth.js +216 -0
- package/dist/cloud/api/auth.js.map +1 -0
- package/dist/cloud/api/billing.d.ts +17 -0
- package/dist/cloud/api/billing.d.ts.map +1 -0
- package/dist/cloud/api/billing.js +353 -0
- package/dist/cloud/api/billing.js.map +1 -0
- package/dist/cloud/api/coordinators.d.ts +8 -0
- package/dist/cloud/api/coordinators.d.ts.map +1 -0
- package/dist/cloud/api/coordinators.js +347 -0
- package/dist/cloud/api/coordinators.js.map +1 -0
- package/dist/cloud/api/daemons.d.ts +12 -0
- package/dist/cloud/api/daemons.d.ts.map +1 -0
- package/dist/cloud/api/daemons.js +320 -0
- package/dist/cloud/api/daemons.js.map +1 -0
- package/dist/cloud/api/middleware/planLimits.d.ts +36 -0
- package/dist/cloud/api/middleware/planLimits.d.ts.map +1 -0
- package/dist/cloud/api/middleware/planLimits.js +164 -0
- package/dist/cloud/api/middleware/planLimits.js.map +1 -0
- package/dist/cloud/api/onboarding.d.ts +8 -0
- package/dist/cloud/api/onboarding.d.ts.map +1 -0
- package/dist/cloud/api/onboarding.js +407 -0
- package/dist/cloud/api/onboarding.js.map +1 -0
- package/dist/cloud/api/providers.d.ts +7 -0
- package/dist/cloud/api/providers.d.ts.map +1 -0
- package/dist/cloud/api/providers.js +435 -0
- package/dist/cloud/api/providers.js.map +1 -0
- package/dist/cloud/api/repos.d.ts +7 -0
- package/dist/cloud/api/repos.d.ts.map +1 -0
- package/dist/cloud/api/repos.js +314 -0
- package/dist/cloud/api/repos.js.map +1 -0
- package/dist/cloud/api/teams.d.ts +7 -0
- package/dist/cloud/api/teams.d.ts.map +1 -0
- package/dist/cloud/api/teams.js +279 -0
- package/dist/cloud/api/teams.js.map +1 -0
- package/dist/cloud/api/usage.d.ts +7 -0
- package/dist/cloud/api/usage.d.ts.map +1 -0
- package/dist/cloud/api/usage.js +98 -0
- package/dist/cloud/api/usage.js.map +1 -0
- package/dist/cloud/api/workspaces.d.ts +7 -0
- package/dist/cloud/api/workspaces.d.ts.map +1 -0
- package/dist/cloud/api/workspaces.js +510 -0
- package/dist/cloud/api/workspaces.js.map +1 -0
- package/dist/cloud/billing/index.d.ts +9 -0
- package/dist/cloud/billing/index.d.ts.map +1 -0
- package/dist/cloud/billing/index.js +9 -0
- package/dist/cloud/billing/index.js.map +1 -0
- package/dist/cloud/billing/plans.d.ts +39 -0
- package/dist/cloud/billing/plans.d.ts.map +1 -0
- package/dist/cloud/billing/plans.js +232 -0
- package/dist/cloud/billing/plans.js.map +1 -0
- package/dist/cloud/billing/service.d.ts +80 -0
- package/dist/cloud/billing/service.d.ts.map +1 -0
- package/dist/cloud/billing/service.js +388 -0
- package/dist/cloud/billing/service.js.map +1 -0
- package/dist/cloud/billing/types.d.ts +135 -0
- package/dist/cloud/billing/types.d.ts.map +1 -0
- package/dist/cloud/billing/types.js +7 -0
- package/dist/cloud/billing/types.js.map +1 -0
- package/dist/cloud/config.d.ts +59 -0
- package/dist/cloud/config.d.ts.map +1 -0
- package/dist/cloud/config.js +83 -0
- package/dist/cloud/config.js.map +1 -0
- package/dist/cloud/db/drizzle.d.ts +132 -0
- package/dist/cloud/db/drizzle.d.ts.map +1 -0
- package/dist/cloud/db/drizzle.js +613 -0
- package/dist/cloud/db/drizzle.js.map +1 -0
- package/dist/cloud/db/index.d.ts +30 -0
- package/dist/cloud/db/index.d.ts.map +1 -0
- package/dist/cloud/db/index.js +44 -0
- package/dist/cloud/db/index.js.map +1 -0
- package/dist/cloud/db/schema.d.ts +1792 -0
- package/dist/cloud/db/schema.d.ts.map +1 -0
- package/dist/cloud/db/schema.js +234 -0
- package/dist/cloud/db/schema.js.map +1 -0
- package/dist/cloud/index.d.ts +11 -0
- package/dist/cloud/index.d.ts.map +1 -0
- package/dist/cloud/index.js +37 -0
- package/dist/cloud/index.js.map +1 -0
- package/dist/cloud/provisioner/index.d.ts +51 -0
- package/dist/cloud/provisioner/index.d.ts.map +1 -0
- package/dist/cloud/provisioner/index.js +676 -0
- package/dist/cloud/provisioner/index.js.map +1 -0
- package/dist/cloud/server.d.ts +16 -0
- package/dist/cloud/server.d.ts.map +1 -0
- package/dist/cloud/server.js +190 -0
- package/dist/cloud/server.js.map +1 -0
- package/dist/cloud/services/coordinator.d.ts +62 -0
- package/dist/cloud/services/coordinator.d.ts.map +1 -0
- package/dist/cloud/services/coordinator.js +389 -0
- package/dist/cloud/services/coordinator.js.map +1 -0
- package/dist/cloud/services/planLimits.d.ts +110 -0
- package/dist/cloud/services/planLimits.d.ts.map +1 -0
- package/dist/cloud/services/planLimits.js +254 -0
- package/dist/cloud/services/planLimits.js.map +1 -0
- package/dist/cloud/vault/index.d.ts +76 -0
- package/dist/cloud/vault/index.d.ts.map +1 -0
- package/dist/cloud/vault/index.js +219 -0
- package/dist/cloud/vault/index.js.map +1 -0
- package/dist/daemon/agent-manager.d.ts +87 -0
- package/dist/daemon/agent-manager.d.ts.map +1 -0
- package/dist/daemon/agent-manager.js +412 -0
- package/dist/daemon/agent-manager.js.map +1 -0
- package/dist/daemon/agent-registry.d.ts +2 -0
- package/dist/daemon/agent-registry.d.ts.map +1 -1
- package/dist/daemon/agent-registry.js +3 -0
- package/dist/daemon/agent-registry.js.map +1 -1
- package/dist/daemon/api.d.ts +69 -0
- package/dist/daemon/api.d.ts.map +1 -0
- package/dist/daemon/api.js +425 -0
- package/dist/daemon/api.js.map +1 -0
- package/dist/daemon/cloud-sync.d.ts +101 -0
- package/dist/daemon/cloud-sync.d.ts.map +1 -0
- package/dist/daemon/cloud-sync.js +261 -0
- package/dist/daemon/cloud-sync.js.map +1 -0
- package/dist/daemon/index.d.ts +4 -0
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +6 -0
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/orchestrator.d.ts +155 -0
- package/dist/daemon/orchestrator.d.ts.map +1 -0
- package/dist/daemon/orchestrator.js +736 -0
- package/dist/daemon/orchestrator.js.map +1 -0
- package/dist/daemon/router.d.ts +24 -0
- package/dist/daemon/router.d.ts.map +1 -1
- package/dist/daemon/router.js +71 -1
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/server.d.ts +37 -0
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +191 -16
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/types.d.ts +127 -0
- package/dist/daemon/types.d.ts.map +1 -0
- package/dist/daemon/types.js +6 -0
- package/dist/daemon/types.js.map +1 -0
- package/dist/daemon/workspace-manager.d.ts +75 -0
- package/dist/daemon/workspace-manager.d.ts.map +1 -0
- package/dist/daemon/workspace-manager.js +289 -0
- package/dist/daemon/workspace-manager.js.map +1 -0
- package/dist/dashboard/out/404.html +1 -1
- package/dist/dashboard/out/_next/static/chunks/693-7b3301d8f6bc5014.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/713-f78477eb185f1f4d.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/766-e53e1cfe39b0b5b5.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/900-037c64bfd797fb2a.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/app/page-e3d9e1f4466b9bae.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/history/page-b6edd4dde8d08194.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/layout-2433bb48965f4333.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/metrics/page-e68825a81db67ba1.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/page-cc108bf68c8a657f.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/pricing/page-d80e03a5297f95b6.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/main-app-5d692157a8eb1fd9.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/{main-e0a1f53fe0617a63.js → main-c2f423b9c9f4591b.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/{webpack-c81f7fd28659d64f.js → webpack-a5acc2831d094776.js} +1 -1
- package/dist/dashboard/out/_next/static/css/79b80143647a07d7.css +1 -0
- package/dist/dashboard/out/_next/static/css/8cf277370ad48cfe.css +1 -0
- package/dist/dashboard/out/alt-logos/agent-relay-logo-128.png +0 -0
- package/dist/dashboard/out/alt-logos/agent-relay-logo-256.png +0 -0
- package/dist/dashboard/out/alt-logos/agent-relay-logo-32.png +0 -0
- package/dist/dashboard/out/alt-logos/agent-relay-logo-512.png +0 -0
- package/dist/dashboard/out/alt-logos/agent-relay-logo-64.png +0 -0
- package/dist/dashboard/out/alt-logos/agent-relay-logo.svg +45 -0
- package/dist/dashboard/out/alt-logos/logo.svg +38 -0
- package/dist/dashboard/out/alt-logos/monogram-logo-128.png +0 -0
- package/dist/dashboard/out/alt-logos/monogram-logo-256.png +0 -0
- package/dist/dashboard/out/alt-logos/monogram-logo-32.png +0 -0
- package/dist/dashboard/out/alt-logos/monogram-logo-512.png +0 -0
- package/dist/dashboard/out/alt-logos/monogram-logo-64.png +0 -0
- package/dist/dashboard/out/alt-logos/monogram-logo.svg +38 -0
- package/dist/dashboard/out/app.html +14 -0
- package/dist/dashboard/out/app.txt +7 -0
- package/dist/dashboard/out/history.html +1 -0
- package/dist/dashboard/out/history.txt +7 -0
- package/dist/dashboard/out/index.html +1 -1
- package/dist/dashboard/out/index.txt +2 -2
- package/dist/dashboard/out/metrics.html +1 -515
- package/dist/dashboard/out/metrics.txt +2 -2
- package/dist/dashboard/out/pricing.html +13 -0
- package/dist/dashboard/out/pricing.txt +7 -0
- package/dist/dashboard-server/metrics.d.ts.map +1 -1
- package/dist/dashboard-server/metrics.js +3 -2
- package/dist/dashboard-server/metrics.js.map +1 -1
- package/dist/dashboard-server/server.d.ts.map +1 -1
- package/dist/dashboard-server/server.js +1279 -56
- package/dist/dashboard-server/server.js.map +1 -1
- package/dist/protocol/types.d.ts +10 -1
- package/dist/protocol/types.d.ts.map +1 -1
- package/dist/resiliency/context-persistence.d.ts +140 -0
- package/dist/resiliency/context-persistence.d.ts.map +1 -0
- package/dist/resiliency/context-persistence.js +397 -0
- package/dist/resiliency/context-persistence.js.map +1 -0
- package/dist/resiliency/health-monitor.d.ts +97 -0
- package/dist/resiliency/health-monitor.d.ts.map +1 -0
- package/dist/resiliency/health-monitor.js +291 -0
- package/dist/resiliency/health-monitor.js.map +1 -0
- package/dist/resiliency/index.d.ts +63 -0
- package/dist/resiliency/index.d.ts.map +1 -0
- package/dist/resiliency/index.js +63 -0
- package/dist/resiliency/index.js.map +1 -0
- package/dist/resiliency/logger.d.ts +114 -0
- package/dist/resiliency/logger.d.ts.map +1 -0
- package/dist/resiliency/logger.js +250 -0
- package/dist/resiliency/logger.js.map +1 -0
- package/dist/resiliency/metrics.d.ts +115 -0
- package/dist/resiliency/metrics.d.ts.map +1 -0
- package/dist/resiliency/metrics.js +239 -0
- package/dist/resiliency/metrics.js.map +1 -0
- package/dist/resiliency/provider-context.d.ts +100 -0
- package/dist/resiliency/provider-context.d.ts.map +1 -0
- package/dist/resiliency/provider-context.js +360 -0
- package/dist/resiliency/provider-context.js.map +1 -0
- package/dist/resiliency/supervisor.d.ts +109 -0
- package/dist/resiliency/supervisor.d.ts.map +1 -0
- package/dist/resiliency/supervisor.js +337 -0
- package/dist/resiliency/supervisor.js.map +1 -0
- package/dist/storage/adapter.d.ts +2 -0
- package/dist/storage/adapter.d.ts.map +1 -1
- package/dist/storage/adapter.js +12 -2
- package/dist/storage/adapter.js.map +1 -1
- package/dist/storage/sqlite-adapter.d.ts.map +1 -1
- package/dist/storage/sqlite-adapter.js +18 -14
- package/dist/storage/sqlite-adapter.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logger.d.ts +40 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +84 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/wrapper/client.d.ts +16 -1
- package/dist/wrapper/client.d.ts.map +1 -1
- package/dist/wrapper/client.js +32 -1
- package/dist/wrapper/client.js.map +1 -1
- package/dist/wrapper/parser.d.ts +3 -0
- package/dist/wrapper/parser.d.ts.map +1 -1
- package/dist/wrapper/parser.js +121 -18
- package/dist/wrapper/parser.js.map +1 -1
- package/dist/wrapper/pty-wrapper.d.ts +28 -1
- package/dist/wrapper/pty-wrapper.d.ts.map +1 -1
- package/dist/wrapper/pty-wrapper.js +166 -30
- package/dist/wrapper/pty-wrapper.js.map +1 -1
- package/dist/wrapper/tmux-wrapper.d.ts +5 -0
- package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
- package/dist/wrapper/tmux-wrapper.js +58 -18
- package/dist/wrapper/tmux-wrapper.js.map +1 -1
- package/docs/CLOUD-ARCHITECTURE.md +652 -0
- package/docs/CLOUD-ONBOARDING-DESIGN.md +1983 -0
- package/docs/TESTING_PRESENCE_FEATURES.md +327 -0
- package/docs/agent-relay-snippet.md +107 -4
- package/docs/guides/CLOUD.md +236 -0
- package/docs/guides/LOCAL.md +535 -0
- package/docs/guides/SELF-HOSTED.md +494 -0
- package/docs/proposals/shadow-as-subagent.md +765 -0
- package/docs/proposals/slack-bot-integration.md +1457 -0
- package/package.json +33 -4
- package/dist/dashboard/out/_next/static/chunks/app/layout-c9d8c5d95e48c6bf.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/metrics/page-8aa9936bc6c771ab.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/page-49055e5d2b5e34ec.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/main-app-bae2e535de00de50.js +0 -1
- package/dist/dashboard/out/_next/static/css/50ed6996e3df7bdd.css +0 -1
- /package/dist/dashboard/out/_next/static/{gZXwjIKGDKJ0hiTH-HMeJ → 6HHWb2ZmnJ4OSm0zUP7h4}/_buildManifest.js +0 -0
- /package/dist/dashboard/out/_next/static/{gZXwjIKGDKJ0hiTH-HMeJ → 6HHWb2ZmnJ4OSm0zUP7h4}/_ssgManifest.js +0 -0
- /package/dist/dashboard/out/_next/static/chunks/{117-3bef7b19f3e60751.js → 117-b2cd8d6485aacf2b.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/{648-6cf686106c891ad3.js → 648-8f3f26864ce515e5.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/app/_not-found/{page-8ff6572bc7c9bc61.js → page-0b990dbb71d72a98.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/{fd9d1056-26bd8d656b496dba.js → fd9d1056-bf46c09eb57e019c.js} +0 -0
|
@@ -3,6 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
3
3
|
import http from 'http';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import fs from 'fs';
|
|
6
|
+
import os from 'os';
|
|
6
7
|
import crypto from 'crypto';
|
|
7
8
|
import { fileURLToPath } from 'url';
|
|
8
9
|
import { SqliteStorageAdapter } from '../storage/sqlite-adapter.js';
|
|
@@ -11,8 +12,88 @@ import { computeNeedsAttention } from './needs-attention.js';
|
|
|
11
12
|
import { computeSystemMetrics, formatPrometheusMetrics } from './metrics.js';
|
|
12
13
|
import { MultiProjectClient } from '../bridge/multi-project-client.js';
|
|
13
14
|
import { AgentSpawner } from '../bridge/spawner.js';
|
|
15
|
+
import { loadTeamsConfig } from '../bridge/teams-config.js';
|
|
14
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
17
|
const __dirname = path.dirname(__filename);
|
|
18
|
+
/**
|
|
19
|
+
* Search for files in a directory matching a query pattern.
|
|
20
|
+
* Uses a simple recursive search with common ignore patterns.
|
|
21
|
+
*/
|
|
22
|
+
async function searchFiles(rootDir, query, limit) {
|
|
23
|
+
const results = [];
|
|
24
|
+
const queryLower = query.toLowerCase();
|
|
25
|
+
// Directories to ignore
|
|
26
|
+
const ignoreDirs = new Set([
|
|
27
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
|
|
28
|
+
'__pycache__', '.venv', 'venv', '.cache', '.turbo', '.vercel',
|
|
29
|
+
'.nuxt', '.output', 'vendor', 'target', '.idea', '.vscode'
|
|
30
|
+
]);
|
|
31
|
+
// File patterns to ignore
|
|
32
|
+
const ignorePatterns = [
|
|
33
|
+
/\.lock$/,
|
|
34
|
+
/\.log$/,
|
|
35
|
+
/\.min\.(js|css)$/,
|
|
36
|
+
/\.map$/,
|
|
37
|
+
/\.d\.ts$/,
|
|
38
|
+
/\.pyc$/,
|
|
39
|
+
];
|
|
40
|
+
const shouldIgnore = (name, isDir) => {
|
|
41
|
+
if (isDir)
|
|
42
|
+
return ignoreDirs.has(name);
|
|
43
|
+
return ignorePatterns.some(pattern => pattern.test(name));
|
|
44
|
+
};
|
|
45
|
+
const matchesQuery = (filePath, fileName) => {
|
|
46
|
+
if (!query)
|
|
47
|
+
return true;
|
|
48
|
+
const pathLower = filePath.toLowerCase();
|
|
49
|
+
const nameLower = fileName.toLowerCase();
|
|
50
|
+
// If query contains '/', match against full path
|
|
51
|
+
if (queryLower.includes('/')) {
|
|
52
|
+
return pathLower.includes(queryLower);
|
|
53
|
+
}
|
|
54
|
+
// Otherwise match against file name or path segments
|
|
55
|
+
return nameLower.includes(queryLower) || pathLower.includes(queryLower);
|
|
56
|
+
};
|
|
57
|
+
const searchDir = async (dir, relativePath = '') => {
|
|
58
|
+
if (results.length >= limit)
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
62
|
+
// Sort: directories first, then alphabetically
|
|
63
|
+
entries.sort((a, b) => {
|
|
64
|
+
if (a.isDirectory() !== b.isDirectory()) {
|
|
65
|
+
return a.isDirectory() ? -1 : 1;
|
|
66
|
+
}
|
|
67
|
+
return a.name.localeCompare(b.name);
|
|
68
|
+
});
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (results.length >= limit)
|
|
71
|
+
break;
|
|
72
|
+
const entryPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
73
|
+
const fullPath = path.join(dir, entry.name);
|
|
74
|
+
if (shouldIgnore(entry.name, entry.isDirectory()))
|
|
75
|
+
continue;
|
|
76
|
+
if (matchesQuery(entryPath, entry.name)) {
|
|
77
|
+
results.push({
|
|
78
|
+
path: entryPath,
|
|
79
|
+
name: entry.name,
|
|
80
|
+
isDirectory: entry.isDirectory(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// Recurse into directories
|
|
84
|
+
if (entry.isDirectory() && results.length < limit) {
|
|
85
|
+
await searchDir(fullPath, entryPath);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// Ignore permission errors, etc.
|
|
91
|
+
console.warn(`[searchFiles] Error reading ${dir}:`, err);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
await searchDir(rootDir);
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
16
97
|
export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPathArg) {
|
|
17
98
|
// Handle overloaded signatures
|
|
18
99
|
const options = typeof portOrOptions === 'number'
|
|
@@ -51,6 +132,45 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
51
132
|
skipUTF8Validation: true,
|
|
52
133
|
maxPayload: 100 * 1024 * 1024
|
|
53
134
|
});
|
|
135
|
+
const wssLogs = new WebSocketServer({
|
|
136
|
+
noServer: true,
|
|
137
|
+
perMessageDeflate: false,
|
|
138
|
+
skipUTF8Validation: true,
|
|
139
|
+
maxPayload: 100 * 1024 * 1024
|
|
140
|
+
});
|
|
141
|
+
const wssPresence = new WebSocketServer({
|
|
142
|
+
noServer: true,
|
|
143
|
+
perMessageDeflate: false,
|
|
144
|
+
skipUTF8Validation: true,
|
|
145
|
+
maxPayload: 1024 * 1024 // 1MB - presence messages are small
|
|
146
|
+
});
|
|
147
|
+
// Track log subscriptions: agentName -> Set of WebSocket clients
|
|
148
|
+
const logSubscriptions = new Map();
|
|
149
|
+
const onlineUsers = new Map();
|
|
150
|
+
// Validation helpers for presence
|
|
151
|
+
const isValidUsername = (username) => {
|
|
152
|
+
if (typeof username !== 'string')
|
|
153
|
+
return false;
|
|
154
|
+
// Username should be 1-39 chars, alphanumeric with hyphens (GitHub username rules)
|
|
155
|
+
return /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(username);
|
|
156
|
+
};
|
|
157
|
+
const isValidAvatarUrl = (url) => {
|
|
158
|
+
if (url === undefined || url === null)
|
|
159
|
+
return true;
|
|
160
|
+
if (typeof url !== 'string')
|
|
161
|
+
return false;
|
|
162
|
+
// Must be a valid HTTPS URL from GitHub or similar known providers
|
|
163
|
+
try {
|
|
164
|
+
const parsed = new URL(url);
|
|
165
|
+
return parsed.protocol === 'https:' &&
|
|
166
|
+
(parsed.hostname === 'avatars.githubusercontent.com' ||
|
|
167
|
+
parsed.hostname === 'github.com' ||
|
|
168
|
+
parsed.hostname.endsWith('.githubusercontent.com'));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
54
174
|
// Manually handle upgrade requests and route to correct WebSocketServer
|
|
55
175
|
server.on('upgrade', (request, socket, head) => {
|
|
56
176
|
const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname;
|
|
@@ -64,6 +184,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
64
184
|
wssBridge.emit('connection', ws, request);
|
|
65
185
|
});
|
|
66
186
|
}
|
|
187
|
+
else if (pathname === '/ws/logs' || pathname.startsWith('/ws/logs/')) {
|
|
188
|
+
wssLogs.handleUpgrade(request, socket, head, (ws) => {
|
|
189
|
+
wssLogs.emit('connection', ws, request);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else if (pathname === '/ws/presence') {
|
|
193
|
+
wssPresence.handleUpgrade(request, socket, head, (ws) => {
|
|
194
|
+
wssPresence.emit('connection', ws, request);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
67
197
|
else {
|
|
68
198
|
// Unknown path - destroy socket
|
|
69
199
|
socket.destroy();
|
|
@@ -76,10 +206,70 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
76
206
|
wssBridge.on('error', (err) => {
|
|
77
207
|
console.error('[dashboard] Bridge WebSocket server error:', err);
|
|
78
208
|
});
|
|
209
|
+
wssLogs.on('error', (err) => {
|
|
210
|
+
console.error('[dashboard] Logs WebSocket server error:', err);
|
|
211
|
+
});
|
|
212
|
+
wssPresence.on('error', (err) => {
|
|
213
|
+
console.error('[dashboard] Presence WebSocket server error:', err);
|
|
214
|
+
});
|
|
79
215
|
if (storage) {
|
|
80
216
|
await storage.init();
|
|
81
217
|
}
|
|
82
|
-
|
|
218
|
+
// Increase JSON body limit for base64 image uploads (10MB)
|
|
219
|
+
app.use(express.json({ limit: '10mb' }));
|
|
220
|
+
// Create attachments directory in user's home directory (~/.relay/attachments)
|
|
221
|
+
// This keeps attachments out of source control while still accessible to agents
|
|
222
|
+
const attachmentsDir = path.join(os.homedir(), '.relay', 'attachments');
|
|
223
|
+
if (!fs.existsSync(attachmentsDir)) {
|
|
224
|
+
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
225
|
+
}
|
|
226
|
+
// Also keep uploads dir for backwards compatibility (URL-based serving)
|
|
227
|
+
const uploadsDir = path.join(dataDir, 'uploads');
|
|
228
|
+
if (!fs.existsSync(uploadsDir)) {
|
|
229
|
+
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
230
|
+
}
|
|
231
|
+
// Auto-evict old attachments (older than 7 days)
|
|
232
|
+
const ATTACHMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
233
|
+
const evictOldAttachments = async () => {
|
|
234
|
+
try {
|
|
235
|
+
const files = await fs.promises.readdir(attachmentsDir);
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
let evictedCount = 0;
|
|
238
|
+
for (const file of files) {
|
|
239
|
+
const filePath = path.join(attachmentsDir, file);
|
|
240
|
+
try {
|
|
241
|
+
const stat = await fs.promises.stat(filePath);
|
|
242
|
+
if (stat.isFile() && (now - stat.mtimeMs) > ATTACHMENT_MAX_AGE_MS) {
|
|
243
|
+
await fs.promises.unlink(filePath);
|
|
244
|
+
evictedCount++;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
// Ignore errors for individual files (may have been deleted)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (evictedCount > 0) {
|
|
252
|
+
console.log(`[dashboard] Evicted ${evictedCount} old attachment(s)`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
console.error('[dashboard] Failed to evict old attachments:', err);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
// Run eviction on startup and every hour
|
|
260
|
+
evictOldAttachments();
|
|
261
|
+
const evictionInterval = setInterval(evictOldAttachments, 60 * 60 * 1000); // 1 hour
|
|
262
|
+
// Clean up interval on process exit
|
|
263
|
+
process.on('beforeExit', () => {
|
|
264
|
+
clearInterval(evictionInterval);
|
|
265
|
+
});
|
|
266
|
+
// Serve uploaded files statically
|
|
267
|
+
app.use('/uploads', express.static(uploadsDir));
|
|
268
|
+
// Serve attachments from ~/.relay/attachments
|
|
269
|
+
app.use('/attachments', express.static(attachmentsDir));
|
|
270
|
+
// In-memory attachment registry (for current session)
|
|
271
|
+
// Attachments are also stored on disk, so this is just for quick lookups
|
|
272
|
+
const attachmentRegistry = new Map();
|
|
83
273
|
// Serve dashboard static files at root (built with `next build` in src/dashboard)
|
|
84
274
|
// __dirname is dist/dashboard-server, dashboard is at ../dashboard/out (relative to dist)
|
|
85
275
|
// But in source it's at ../dashboard/out (relative to src/dashboard-server)
|
|
@@ -99,39 +289,70 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
99
289
|
else {
|
|
100
290
|
console.error('[dashboard] Dashboard not found at:', dashboardDistDir, 'or', dashboardSourceDir);
|
|
101
291
|
}
|
|
102
|
-
// Relay
|
|
292
|
+
// Relay clients for sending messages from dashboard
|
|
293
|
+
// Map of senderName -> RelayClient for per-user connections
|
|
103
294
|
const socketPath = path.join(dataDir, 'relay.sock');
|
|
104
|
-
|
|
105
|
-
|
|
295
|
+
const relayClients = new Map();
|
|
296
|
+
// Track pending client connections to prevent race conditions
|
|
297
|
+
const pendingConnections = new Map();
|
|
298
|
+
// Get or create a relay client for a specific sender
|
|
299
|
+
const getRelayClient = async (senderName = 'Dashboard') => {
|
|
300
|
+
// Check if we already have a connected client for this sender
|
|
301
|
+
const existing = relayClients.get(senderName);
|
|
302
|
+
if (existing && existing.state === 'READY') {
|
|
303
|
+
return existing;
|
|
304
|
+
}
|
|
305
|
+
// Check if there's already a pending connection for this sender
|
|
306
|
+
const pending = pendingConnections.get(senderName);
|
|
307
|
+
if (pending) {
|
|
308
|
+
return pending;
|
|
309
|
+
}
|
|
106
310
|
// Only attempt connection if socket exists (daemon is running)
|
|
107
311
|
if (!fs.existsSync(socketPath)) {
|
|
108
312
|
console.log('[dashboard] Relay socket not found, messaging disabled');
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
relayClient = new RelayClient({
|
|
112
|
-
socketPath,
|
|
113
|
-
agentName: 'Dashboard',
|
|
114
|
-
cli: 'dashboard',
|
|
115
|
-
reconnect: true,
|
|
116
|
-
maxReconnectAttempts: 5,
|
|
117
|
-
});
|
|
118
|
-
relayClient.onError = (err) => {
|
|
119
|
-
console.error('[dashboard] Relay client error:', err.message);
|
|
120
|
-
};
|
|
121
|
-
relayClient.onStateChange = (state) => {
|
|
122
|
-
console.log(`[dashboard] Relay client state: ${state}`);
|
|
123
|
-
};
|
|
124
|
-
try {
|
|
125
|
-
await relayClient.connect();
|
|
126
|
-
console.log('[dashboard] Connected to relay daemon');
|
|
127
|
-
}
|
|
128
|
-
catch (err) {
|
|
129
|
-
console.error('[dashboard] Failed to connect to relay daemon:', err);
|
|
130
|
-
relayClient = undefined;
|
|
313
|
+
return undefined;
|
|
131
314
|
}
|
|
315
|
+
// Create connection promise to prevent race conditions
|
|
316
|
+
const connectionPromise = (async () => {
|
|
317
|
+
// Create new client for this sender
|
|
318
|
+
const client = new RelayClient({
|
|
319
|
+
socketPath,
|
|
320
|
+
agentName: senderName,
|
|
321
|
+
cli: 'dashboard',
|
|
322
|
+
reconnect: true,
|
|
323
|
+
maxReconnectAttempts: 5,
|
|
324
|
+
});
|
|
325
|
+
client.onError = (err) => {
|
|
326
|
+
console.error(`[dashboard] Relay client error for ${senderName}:`, err.message);
|
|
327
|
+
};
|
|
328
|
+
client.onStateChange = (state) => {
|
|
329
|
+
console.log(`[dashboard] Relay client for ${senderName} state: ${state}`);
|
|
330
|
+
// Clean up disconnected clients
|
|
331
|
+
if (state === 'DISCONNECTED') {
|
|
332
|
+
relayClients.delete(senderName);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
try {
|
|
336
|
+
await client.connect();
|
|
337
|
+
relayClients.set(senderName, client);
|
|
338
|
+
console.log(`[dashboard] Connected to relay daemon as ${senderName}`);
|
|
339
|
+
return client;
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
console.error(`[dashboard] Failed to connect to relay daemon as ${senderName}:`, err);
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
// Clean up pending connection
|
|
347
|
+
pendingConnections.delete(senderName);
|
|
348
|
+
}
|
|
349
|
+
})();
|
|
350
|
+
// Store the pending connection
|
|
351
|
+
pendingConnections.set(senderName, connectionPromise);
|
|
352
|
+
return connectionPromise;
|
|
132
353
|
};
|
|
133
|
-
// Start relay client connection (non-blocking)
|
|
134
|
-
|
|
354
|
+
// Start default relay client connection (non-blocking)
|
|
355
|
+
getRelayClient('Dashboard').catch(() => { });
|
|
135
356
|
// Bridge client for cross-project messaging
|
|
136
357
|
let bridgeClient;
|
|
137
358
|
let bridgeClientConnecting = false;
|
|
@@ -188,26 +409,132 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
188
409
|
};
|
|
189
410
|
// Start bridge client connection (non-blocking)
|
|
190
411
|
connectBridgeClient().catch(() => { });
|
|
412
|
+
// Helper to check if an agent is online (seen within heartbeat timeout window)
|
|
413
|
+
// Uses 30 second threshold to align with heartbeat timeout (5s * 6 multiplier)
|
|
414
|
+
const isAgentOnline = (agentName) => {
|
|
415
|
+
if (agentName === '*')
|
|
416
|
+
return true; // Broadcast always allowed
|
|
417
|
+
const agentsPath = path.join(teamDir, 'agents.json');
|
|
418
|
+
if (!fs.existsSync(agentsPath))
|
|
419
|
+
return false;
|
|
420
|
+
try {
|
|
421
|
+
const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
|
|
422
|
+
const agent = data.agents?.find((a) => a.name === agentName);
|
|
423
|
+
if (!agent || !agent.lastSeen)
|
|
424
|
+
return false;
|
|
425
|
+
const thirtySecondsAgo = Date.now() - 30 * 1000;
|
|
426
|
+
return new Date(agent.lastSeen).getTime() > thirtySecondsAgo;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
// Helper to get team members from teams.json, agents.json, and spawner's active workers
|
|
433
|
+
const getTeamMembers = (teamName) => {
|
|
434
|
+
const members = new Set();
|
|
435
|
+
// Check teams.json first - this is the source of truth for team definitions
|
|
436
|
+
const teamsConfig = loadTeamsConfig(projectRoot || dataDir);
|
|
437
|
+
if (teamsConfig && teamsConfig.team === teamName) {
|
|
438
|
+
for (const agent of teamsConfig.agents) {
|
|
439
|
+
members.add(agent.name);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Check spawner's active workers (they have accurate team info for spawned agents)
|
|
443
|
+
if (spawner) {
|
|
444
|
+
const activeWorkers = spawner.getActiveWorkers();
|
|
445
|
+
for (const worker of activeWorkers) {
|
|
446
|
+
if (worker.team === teamName) {
|
|
447
|
+
members.add(worker.name);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Also check agents.json for persisted team info
|
|
452
|
+
const agentsPath = path.join(teamDir, 'agents.json');
|
|
453
|
+
if (fs.existsSync(agentsPath)) {
|
|
454
|
+
try {
|
|
455
|
+
const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
|
|
456
|
+
for (const agent of (data.agents || [])) {
|
|
457
|
+
if (agent.team === teamName) {
|
|
458
|
+
members.add(agent.name);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// Ignore parse errors
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return Array.from(members);
|
|
467
|
+
};
|
|
191
468
|
// API endpoint to send messages
|
|
192
469
|
app.post('/api/send', async (req, res) => {
|
|
193
|
-
const { to, message, thread } = req.body;
|
|
470
|
+
const { to, message, thread, attachments: attachmentIds, from: senderName } = req.body;
|
|
194
471
|
if (!to || !message) {
|
|
195
472
|
return res.status(400).json({ error: 'Missing "to" or "message" field' });
|
|
196
473
|
}
|
|
197
|
-
if
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
474
|
+
// Check if this is a team mention (team:teamName)
|
|
475
|
+
const teamMatch = to.match(/^team:(.+)$/);
|
|
476
|
+
let targets;
|
|
477
|
+
if (teamMatch) {
|
|
478
|
+
const teamName = teamMatch[1];
|
|
479
|
+
const members = getTeamMembers(teamName);
|
|
480
|
+
if (members.length === 0) {
|
|
481
|
+
return res.status(404).json({ error: `No agents found in team "${teamName}"` });
|
|
482
|
+
}
|
|
483
|
+
// Filter to only online members
|
|
484
|
+
targets = members.filter(isAgentOnline);
|
|
485
|
+
if (targets.length === 0) {
|
|
486
|
+
return res.status(404).json({ error: `No online agents in team "${teamName}"` });
|
|
202
487
|
}
|
|
203
488
|
}
|
|
489
|
+
else {
|
|
490
|
+
// Fail fast if target agent is offline (except broadcasts)
|
|
491
|
+
if (to !== '*' && !isAgentOnline(to)) {
|
|
492
|
+
return res.status(404).json({ error: `Agent "${to}" is not online` });
|
|
493
|
+
}
|
|
494
|
+
targets = [to];
|
|
495
|
+
}
|
|
496
|
+
// Get or create relay client for this sender (defaults to 'Dashboard' for non-cloud mode)
|
|
497
|
+
const relayClient = await getRelayClient(senderName || 'Dashboard');
|
|
498
|
+
if (!relayClient || relayClient.state !== 'READY') {
|
|
499
|
+
return res.status(503).json({ error: 'Relay daemon not connected' });
|
|
500
|
+
}
|
|
204
501
|
try {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
502
|
+
// Resolve attachments if provided
|
|
503
|
+
let attachments;
|
|
504
|
+
if (attachmentIds && Array.isArray(attachmentIds) && attachmentIds.length > 0) {
|
|
505
|
+
attachments = [];
|
|
506
|
+
for (const id of attachmentIds) {
|
|
507
|
+
const attachment = attachmentRegistry.get(id);
|
|
508
|
+
if (attachment) {
|
|
509
|
+
attachments.push(attachment);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Include attachments and channel context in the message data field
|
|
514
|
+
// For broadcasts (to='*'), include channel: 'general' so replies can be routed back
|
|
515
|
+
const isBroadcast = targets.length === 1 && targets[0] === '*';
|
|
516
|
+
const messageData = {};
|
|
517
|
+
if (attachments && attachments.length > 0) {
|
|
518
|
+
messageData.attachments = attachments;
|
|
519
|
+
}
|
|
520
|
+
if (isBroadcast) {
|
|
521
|
+
messageData.channel = 'general';
|
|
522
|
+
}
|
|
523
|
+
const hasMessageData = Object.keys(messageData).length > 0;
|
|
524
|
+
// Send to all targets (single agent, team members, or broadcast)
|
|
525
|
+
let allSent = true;
|
|
526
|
+
for (const target of targets) {
|
|
527
|
+
const sent = relayClient.sendMessage(target, message, 'message', hasMessageData ? messageData : undefined, thread);
|
|
528
|
+
if (!sent) {
|
|
529
|
+
allSent = false;
|
|
530
|
+
console.error(`[dashboard] Failed to send message to ${target}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (allSent) {
|
|
534
|
+
res.json({ success: true, sentTo: targets.length > 1 ? targets : targets[0] });
|
|
208
535
|
}
|
|
209
536
|
else {
|
|
210
|
-
res.status(500).json({ error: 'Failed to send message' });
|
|
537
|
+
res.status(500).json({ error: 'Failed to send message to some recipients' });
|
|
211
538
|
}
|
|
212
539
|
}
|
|
213
540
|
catch (err) {
|
|
@@ -242,6 +569,91 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
242
569
|
res.status(500).json({ error: 'Failed to send bridge message' });
|
|
243
570
|
}
|
|
244
571
|
});
|
|
572
|
+
// API endpoint to upload attachments (images/screenshots)
|
|
573
|
+
app.post('/api/upload', async (req, res) => {
|
|
574
|
+
const { filename, mimeType, data } = req.body;
|
|
575
|
+
// Validate required fields
|
|
576
|
+
if (!filename || !mimeType || !data) {
|
|
577
|
+
return res.status(400).json({
|
|
578
|
+
success: false,
|
|
579
|
+
error: 'Missing required fields: filename, mimeType, data',
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
// Validate mime type (only allow images for now)
|
|
583
|
+
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
|
|
584
|
+
if (!allowedTypes.includes(mimeType)) {
|
|
585
|
+
return res.status(400).json({
|
|
586
|
+
success: false,
|
|
587
|
+
error: `Invalid file type. Allowed types: ${allowedTypes.join(', ')}`,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
// Decode base64 data
|
|
592
|
+
const base64Data = data.replace(/^data:[^;]+;base64,/, '');
|
|
593
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
594
|
+
// Generate unique ID and filename for the attachment
|
|
595
|
+
const attachmentId = crypto.randomUUID();
|
|
596
|
+
const timestamp = Date.now();
|
|
597
|
+
const ext = mimeType.split('/')[1].replace('svg+xml', 'svg');
|
|
598
|
+
// Use format: {messageId}-{timestamp}.{ext} for unique, identifiable filenames
|
|
599
|
+
const safeFilename = `${attachmentId.substring(0, 8)}-${timestamp}.${ext}`;
|
|
600
|
+
// Save to ~/.relay/attachments/ directory for agents to access
|
|
601
|
+
const attachmentFilePath = path.join(attachmentsDir, safeFilename);
|
|
602
|
+
fs.writeFileSync(attachmentFilePath, buffer);
|
|
603
|
+
// Create attachment record with file path for agents
|
|
604
|
+
const attachment = {
|
|
605
|
+
id: attachmentId,
|
|
606
|
+
filename: filename,
|
|
607
|
+
mimeType: mimeType,
|
|
608
|
+
size: buffer.length,
|
|
609
|
+
url: `/attachments/${safeFilename}`,
|
|
610
|
+
// Include absolute file path so agents can read the file directly
|
|
611
|
+
filePath: attachmentFilePath,
|
|
612
|
+
// Include base64 data for agents that can't access the file
|
|
613
|
+
data: data,
|
|
614
|
+
};
|
|
615
|
+
// Store in registry for lookup when sending messages
|
|
616
|
+
attachmentRegistry.set(attachmentId, attachment);
|
|
617
|
+
console.log(`[dashboard] Uploaded attachment: ${filename} (${buffer.length} bytes) -> ${attachmentFilePath}`);
|
|
618
|
+
res.json({
|
|
619
|
+
success: true,
|
|
620
|
+
attachment: {
|
|
621
|
+
id: attachment.id,
|
|
622
|
+
filename: attachment.filename,
|
|
623
|
+
mimeType: attachment.mimeType,
|
|
624
|
+
size: attachment.size,
|
|
625
|
+
url: attachment.url,
|
|
626
|
+
filePath: attachment.filePath,
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
catch (err) {
|
|
631
|
+
console.error('[dashboard] Upload failed:', err);
|
|
632
|
+
res.status(500).json({
|
|
633
|
+
success: false,
|
|
634
|
+
error: 'Failed to upload file',
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
// API endpoint to get attachment by ID
|
|
639
|
+
app.get('/api/attachment/:id', (req, res) => {
|
|
640
|
+
const { id } = req.params;
|
|
641
|
+
const attachment = attachmentRegistry.get(id);
|
|
642
|
+
if (!attachment) {
|
|
643
|
+
return res.status(404).json({ error: 'Attachment not found' });
|
|
644
|
+
}
|
|
645
|
+
res.json({
|
|
646
|
+
success: true,
|
|
647
|
+
attachment: {
|
|
648
|
+
id: attachment.id,
|
|
649
|
+
filename: attachment.filename,
|
|
650
|
+
mimeType: attachment.mimeType,
|
|
651
|
+
size: attachment.size,
|
|
652
|
+
url: attachment.url,
|
|
653
|
+
filePath: attachment.filePath,
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
});
|
|
245
657
|
const getTeamData = () => {
|
|
246
658
|
// Try team.json first (file-based team mode)
|
|
247
659
|
const teamPath = path.join(teamDir, 'team.json');
|
|
@@ -266,6 +678,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
266
678
|
cli: a.cli ?? 'Unknown',
|
|
267
679
|
lastSeen: a.lastSeen ?? a.connectedAt,
|
|
268
680
|
lastActive: a.lastSeen ?? a.connectedAt,
|
|
681
|
+
team: a.team,
|
|
269
682
|
})),
|
|
270
683
|
};
|
|
271
684
|
}
|
|
@@ -315,16 +728,40 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
315
728
|
return [];
|
|
316
729
|
}
|
|
317
730
|
};
|
|
731
|
+
// Helper to check if an agent name is internal/system (should be hidden from UI)
|
|
732
|
+
// Convention: agent names starting with __ are internal (e.g., __spawner__, __DashboardBridge__)
|
|
733
|
+
const isInternalAgent = (name) => {
|
|
734
|
+
return name.startsWith('__');
|
|
735
|
+
};
|
|
318
736
|
const mapStoredMessages = (rows) => rows
|
|
319
|
-
|
|
320
|
-
from
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
737
|
+
// Filter out messages from/to internal system agents (e.g., __spawner__)
|
|
738
|
+
.filter((row) => !isInternalAgent(row.from) && !isInternalAgent(row.to))
|
|
739
|
+
.map((row) => {
|
|
740
|
+
// Extract attachments and channel from the data field if present
|
|
741
|
+
let attachments;
|
|
742
|
+
let channel;
|
|
743
|
+
if (row.data && typeof row.data === 'object') {
|
|
744
|
+
if ('attachments' in row.data) {
|
|
745
|
+
attachments = row.data.attachments;
|
|
746
|
+
}
|
|
747
|
+
if ('channel' in row.data) {
|
|
748
|
+
channel = row.data.channel;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
from: row.from,
|
|
753
|
+
to: row.to,
|
|
754
|
+
content: row.body,
|
|
755
|
+
timestamp: new Date(row.ts).toISOString(),
|
|
756
|
+
id: row.id,
|
|
757
|
+
thread: row.thread,
|
|
758
|
+
isBroadcast: row.is_broadcast,
|
|
759
|
+
replyCount: row.replyCount,
|
|
760
|
+
status: row.status,
|
|
761
|
+
attachments,
|
|
762
|
+
channel,
|
|
763
|
+
};
|
|
764
|
+
});
|
|
328
765
|
const getMessages = async (agents) => {
|
|
329
766
|
if (storage) {
|
|
330
767
|
const rows = await storage.getMessages({ limit: 100, order: 'desc' });
|
|
@@ -397,6 +834,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
397
834
|
lastSeen: a.lastSeen,
|
|
398
835
|
lastActive: a.lastActive,
|
|
399
836
|
needsAttention: false,
|
|
837
|
+
team: a.team,
|
|
400
838
|
});
|
|
401
839
|
});
|
|
402
840
|
// Update inbox counts if fallback mode; if storage, count messages addressed to agent
|
|
@@ -443,7 +881,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
443
881
|
}
|
|
444
882
|
});
|
|
445
883
|
// Read processing state from daemon
|
|
446
|
-
const processingStatePath = path.join(
|
|
884
|
+
const processingStatePath = path.join(teamDir, 'processing-state.json');
|
|
447
885
|
if (fs.existsSync(processingStatePath)) {
|
|
448
886
|
try {
|
|
449
887
|
const processingData = JSON.parse(fs.readFileSync(processingStatePath, 'utf-8'));
|
|
@@ -473,6 +911,18 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
473
911
|
}
|
|
474
912
|
}
|
|
475
913
|
}
|
|
914
|
+
// Set team from teams.json for agents that don't have a team yet
|
|
915
|
+
// This ensures agents defined in teams.json are associated with their team
|
|
916
|
+
// even if they weren't spawned via auto-spawn
|
|
917
|
+
const teamsConfig = loadTeamsConfig(projectRoot || dataDir);
|
|
918
|
+
if (teamsConfig) {
|
|
919
|
+
for (const teamAgent of teamsConfig.agents) {
|
|
920
|
+
const agent = agentsMap.get(teamAgent.name);
|
|
921
|
+
if (agent && !agent.team) {
|
|
922
|
+
agent.team = teamsConfig.team;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
476
926
|
// Fetch sessions and summaries in parallel
|
|
477
927
|
const [sessions, summaries] = await Promise.all([
|
|
478
928
|
getRecentSessions(),
|
|
@@ -480,9 +930,11 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
480
930
|
]);
|
|
481
931
|
// Filter agents:
|
|
482
932
|
// 1. Exclude "Dashboard" (internal agent, not a real team member)
|
|
483
|
-
// 2. Exclude offline agents (no lastSeen or lastSeen >
|
|
933
|
+
// 2. Exclude offline agents (no lastSeen or lastSeen > threshold)
|
|
484
934
|
const now = Date.now();
|
|
485
|
-
|
|
935
|
+
// 30 seconds - aligns with heartbeat timeout (5s heartbeat * 6 multiplier = 30s)
|
|
936
|
+
// This ensures agents disappear quickly after they stop responding to heartbeats
|
|
937
|
+
const OFFLINE_THRESHOLD_MS = 30 * 1000;
|
|
486
938
|
const filteredAgents = Array.from(agentsMap.values()).filter(agent => {
|
|
487
939
|
// Exclude Dashboard
|
|
488
940
|
if (agent.name === 'Dashboard')
|
|
@@ -558,13 +1010,13 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
558
1010
|
try {
|
|
559
1011
|
const agentsData = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
|
|
560
1012
|
if (agentsData.agents && Array.isArray(agentsData.agents)) {
|
|
561
|
-
// Filter to only show online agents (seen
|
|
562
|
-
const
|
|
1013
|
+
// Filter to only show online agents (seen within 30 seconds - aligns with heartbeat timeout)
|
|
1014
|
+
const thirtySecondsAgo = Date.now() - 30 * 1000;
|
|
563
1015
|
project.agents = agentsData.agents
|
|
564
1016
|
.filter((a) => {
|
|
565
1017
|
if (!a.lastSeen)
|
|
566
1018
|
return false;
|
|
567
|
-
return new Date(a.lastSeen).getTime() >
|
|
1019
|
+
return new Date(a.lastSeen).getTime() > thirtySecondsAgo;
|
|
568
1020
|
})
|
|
569
1021
|
.map((a) => ({
|
|
570
1022
|
name: a.name,
|
|
@@ -674,12 +1126,375 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
674
1126
|
console.log('[dashboard] Bridge WebSocket client disconnected, code:', code, 'reason:', reason?.toString() || 'none');
|
|
675
1127
|
});
|
|
676
1128
|
});
|
|
1129
|
+
// Track alive status for ping/pong keepalive on log connections
|
|
1130
|
+
const logClientAlive = new WeakMap();
|
|
1131
|
+
// Ping interval for log WebSocket connections (30 seconds)
|
|
1132
|
+
// This prevents TCP/proxy timeouts from killing idle connections
|
|
1133
|
+
const LOG_PING_INTERVAL_MS = 30000;
|
|
1134
|
+
const logPingInterval = setInterval(() => {
|
|
1135
|
+
wssLogs.clients.forEach((ws) => {
|
|
1136
|
+
if (logClientAlive.get(ws) === false) {
|
|
1137
|
+
// Client didn't respond to last ping - close gracefully
|
|
1138
|
+
console.log('[dashboard] Logs WebSocket client unresponsive, closing gracefully');
|
|
1139
|
+
ws.close(1000, 'unresponsive');
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
// Mark as not alive until we get a pong
|
|
1143
|
+
logClientAlive.set(ws, false);
|
|
1144
|
+
ws.ping();
|
|
1145
|
+
});
|
|
1146
|
+
}, LOG_PING_INTERVAL_MS);
|
|
1147
|
+
// Clean up ping interval on server close
|
|
1148
|
+
wssLogs.on('close', () => {
|
|
1149
|
+
clearInterval(logPingInterval);
|
|
1150
|
+
});
|
|
1151
|
+
// Handle logs WebSocket connections for live log streaming
|
|
1152
|
+
wssLogs.on('connection', (ws, req) => {
|
|
1153
|
+
console.log('[dashboard] Logs WebSocket client connected');
|
|
1154
|
+
const clientSubscriptions = new Set();
|
|
1155
|
+
// Mark client as alive initially
|
|
1156
|
+
logClientAlive.set(ws, true);
|
|
1157
|
+
// Handle pong responses (keep connection alive)
|
|
1158
|
+
ws.on('pong', () => {
|
|
1159
|
+
logClientAlive.set(ws, true);
|
|
1160
|
+
});
|
|
1161
|
+
// Helper to check if agent is daemon-connected (from agents.json)
|
|
1162
|
+
const isDaemonConnected = (agentName) => {
|
|
1163
|
+
const agentsPath = path.join(teamDir, 'agents.json');
|
|
1164
|
+
if (!fs.existsSync(agentsPath))
|
|
1165
|
+
return false;
|
|
1166
|
+
try {
|
|
1167
|
+
const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
|
|
1168
|
+
return data.agents?.some((a) => a.name === agentName) ?? false;
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
// Helper to subscribe to an agent
|
|
1175
|
+
const subscribeToAgent = (agentName) => {
|
|
1176
|
+
const isSpawned = spawner?.hasWorker(agentName) ?? false;
|
|
1177
|
+
const isDaemon = isDaemonConnected(agentName);
|
|
1178
|
+
// Check if agent exists (either spawned or daemon-connected)
|
|
1179
|
+
if (!isSpawned && !isDaemon) {
|
|
1180
|
+
ws.send(JSON.stringify({
|
|
1181
|
+
type: 'error',
|
|
1182
|
+
agent: agentName,
|
|
1183
|
+
error: `Agent ${agentName} not found`,
|
|
1184
|
+
}));
|
|
1185
|
+
// Close with custom code 4404 to signal "agent not found" - client should not reconnect
|
|
1186
|
+
ws.close(4404, 'Agent not found');
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
// Add to subscriptions
|
|
1190
|
+
clientSubscriptions.add(agentName);
|
|
1191
|
+
if (!logSubscriptions.has(agentName)) {
|
|
1192
|
+
logSubscriptions.set(agentName, new Set());
|
|
1193
|
+
}
|
|
1194
|
+
logSubscriptions.get(agentName).add(ws);
|
|
1195
|
+
console.log(`[dashboard] Client subscribed to logs for: ${agentName} (spawned: ${isSpawned}, daemon: ${isDaemon})`);
|
|
1196
|
+
if (isSpawned && spawner) {
|
|
1197
|
+
// Send initial log history for spawned agents
|
|
1198
|
+
const lines = spawner.getWorkerOutput(agentName, 200);
|
|
1199
|
+
ws.send(JSON.stringify({
|
|
1200
|
+
type: 'history',
|
|
1201
|
+
agent: agentName,
|
|
1202
|
+
lines: lines || [],
|
|
1203
|
+
}));
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
// For daemon-connected agents, explain that PTY output isn't available
|
|
1207
|
+
ws.send(JSON.stringify({
|
|
1208
|
+
type: 'history',
|
|
1209
|
+
agent: agentName,
|
|
1210
|
+
lines: [`[${agentName} is a daemon-connected agent - PTY output not available. Showing relay messages only.]`],
|
|
1211
|
+
}));
|
|
1212
|
+
}
|
|
1213
|
+
ws.send(JSON.stringify({
|
|
1214
|
+
type: 'subscribed',
|
|
1215
|
+
agent: agentName,
|
|
1216
|
+
}));
|
|
1217
|
+
return true;
|
|
1218
|
+
};
|
|
1219
|
+
// Check if agent name is in URL path: /ws/logs/:agentName
|
|
1220
|
+
const pathname = new URL(req.url || '', `http://${req.headers.host}`).pathname;
|
|
1221
|
+
const pathMatch = pathname.match(/^\/ws\/logs\/(.+)$/);
|
|
1222
|
+
if (pathMatch) {
|
|
1223
|
+
const agentName = decodeURIComponent(pathMatch[1]);
|
|
1224
|
+
subscribeToAgent(agentName);
|
|
1225
|
+
}
|
|
1226
|
+
ws.on('message', (data) => {
|
|
1227
|
+
try {
|
|
1228
|
+
const msg = JSON.parse(data.toString());
|
|
1229
|
+
// Subscribe to agent logs
|
|
1230
|
+
if (msg.subscribe && typeof msg.subscribe === 'string') {
|
|
1231
|
+
subscribeToAgent(msg.subscribe);
|
|
1232
|
+
}
|
|
1233
|
+
// Unsubscribe from agent logs
|
|
1234
|
+
if (msg.unsubscribe && typeof msg.unsubscribe === 'string') {
|
|
1235
|
+
const agentName = msg.unsubscribe;
|
|
1236
|
+
clientSubscriptions.delete(agentName);
|
|
1237
|
+
logSubscriptions.get(agentName)?.delete(ws);
|
|
1238
|
+
console.log(`[dashboard] Client unsubscribed from logs for: ${agentName}`);
|
|
1239
|
+
ws.send(JSON.stringify({
|
|
1240
|
+
type: 'unsubscribed',
|
|
1241
|
+
agent: agentName,
|
|
1242
|
+
}));
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
catch (err) {
|
|
1246
|
+
console.error('[dashboard] Invalid logs WebSocket message:', err);
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
ws.on('error', (err) => {
|
|
1250
|
+
console.error('[dashboard] Logs WebSocket client error:', err);
|
|
1251
|
+
});
|
|
1252
|
+
ws.on('close', (code, reason) => {
|
|
1253
|
+
// Clean up subscriptions on disconnect
|
|
1254
|
+
for (const agentName of clientSubscriptions) {
|
|
1255
|
+
logSubscriptions.get(agentName)?.delete(ws);
|
|
1256
|
+
}
|
|
1257
|
+
const reasonStr = reason?.toString() || 'no reason';
|
|
1258
|
+
console.log(`[dashboard] Logs WebSocket client disconnected (code: ${code}, reason: ${reasonStr})`);
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
// Function to broadcast log output to subscribed clients
|
|
1262
|
+
const broadcastLogOutput = (agentName, output) => {
|
|
1263
|
+
const clients = logSubscriptions.get(agentName);
|
|
1264
|
+
if (!clients || clients.size === 0)
|
|
1265
|
+
return;
|
|
1266
|
+
const payload = JSON.stringify({
|
|
1267
|
+
type: 'output',
|
|
1268
|
+
agent: agentName,
|
|
1269
|
+
data: output,
|
|
1270
|
+
timestamp: new Date().toISOString(),
|
|
1271
|
+
});
|
|
1272
|
+
for (const client of clients) {
|
|
1273
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1274
|
+
client.send(payload);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
// Expose broadcastLogOutput for PTY wrappers to call
|
|
1279
|
+
global.__broadcastLogOutput = broadcastLogOutput;
|
|
1280
|
+
// ===== Presence WebSocket Handler =====
|
|
1281
|
+
// Helper to broadcast to all presence clients
|
|
1282
|
+
const broadcastPresence = (message, exclude) => {
|
|
1283
|
+
const payload = JSON.stringify(message);
|
|
1284
|
+
wssPresence.clients.forEach((client) => {
|
|
1285
|
+
if (client !== exclude && client.readyState === WebSocket.OPEN) {
|
|
1286
|
+
client.send(payload);
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
};
|
|
1290
|
+
// Helper to get online users list (without ws references)
|
|
1291
|
+
const getOnlineUsersList = () => {
|
|
1292
|
+
return Array.from(onlineUsers.values()).map((state) => state.info);
|
|
1293
|
+
};
|
|
1294
|
+
wssPresence.on('connection', (ws) => {
|
|
1295
|
+
console.log('[dashboard] Presence WebSocket client connected');
|
|
1296
|
+
let clientUsername;
|
|
1297
|
+
ws.on('message', (data) => {
|
|
1298
|
+
try {
|
|
1299
|
+
const msg = JSON.parse(data.toString());
|
|
1300
|
+
if (msg.type === 'presence') {
|
|
1301
|
+
if (msg.action === 'join' && msg.user?.username) {
|
|
1302
|
+
const username = msg.user.username;
|
|
1303
|
+
const avatarUrl = msg.user.avatarUrl;
|
|
1304
|
+
// Validate inputs
|
|
1305
|
+
if (!isValidUsername(username)) {
|
|
1306
|
+
console.warn(`[dashboard] Invalid username rejected: ${username}`);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (!isValidAvatarUrl(avatarUrl)) {
|
|
1310
|
+
console.warn(`[dashboard] Invalid avatar URL rejected for user ${username}`);
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
clientUsername = username;
|
|
1314
|
+
const now = new Date().toISOString();
|
|
1315
|
+
// Check if user already has connections (multi-tab support)
|
|
1316
|
+
const existing = onlineUsers.get(username);
|
|
1317
|
+
if (existing) {
|
|
1318
|
+
// Add this connection to existing user
|
|
1319
|
+
existing.connections.add(ws);
|
|
1320
|
+
existing.info.lastSeen = now;
|
|
1321
|
+
console.log(`[dashboard] User ${username} opened new tab (${existing.connections.size} connections)`);
|
|
1322
|
+
}
|
|
1323
|
+
else {
|
|
1324
|
+
// New user - create presence state
|
|
1325
|
+
onlineUsers.set(username, {
|
|
1326
|
+
info: {
|
|
1327
|
+
username,
|
|
1328
|
+
avatarUrl,
|
|
1329
|
+
connectedAt: now,
|
|
1330
|
+
lastSeen: now,
|
|
1331
|
+
},
|
|
1332
|
+
connections: new Set([ws]),
|
|
1333
|
+
});
|
|
1334
|
+
console.log(`[dashboard] User ${username} came online`);
|
|
1335
|
+
// Broadcast join to all other clients (only for truly new users)
|
|
1336
|
+
broadcastPresence({
|
|
1337
|
+
type: 'presence_join',
|
|
1338
|
+
user: {
|
|
1339
|
+
username,
|
|
1340
|
+
avatarUrl,
|
|
1341
|
+
connectedAt: now,
|
|
1342
|
+
lastSeen: now,
|
|
1343
|
+
},
|
|
1344
|
+
}, ws);
|
|
1345
|
+
}
|
|
1346
|
+
// Send current online users list to the new client
|
|
1347
|
+
ws.send(JSON.stringify({
|
|
1348
|
+
type: 'presence_list',
|
|
1349
|
+
users: getOnlineUsersList(),
|
|
1350
|
+
}));
|
|
1351
|
+
}
|
|
1352
|
+
else if (msg.action === 'leave') {
|
|
1353
|
+
// Security: Only allow leaving your own username
|
|
1354
|
+
// Must have authenticated first
|
|
1355
|
+
if (!clientUsername) {
|
|
1356
|
+
console.warn(`[dashboard] Security: Unauthenticated leave attempt`);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (msg.username !== clientUsername) {
|
|
1360
|
+
console.warn(`[dashboard] Security: User ${clientUsername} tried to remove ${msg.username}`);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
// Remove this connection from the user's set
|
|
1364
|
+
const username = clientUsername; // Narrow type for TypeScript
|
|
1365
|
+
const userState = onlineUsers.get(username);
|
|
1366
|
+
if (userState) {
|
|
1367
|
+
userState.connections.delete(ws);
|
|
1368
|
+
// Only broadcast leave if no more connections
|
|
1369
|
+
if (userState.connections.size === 0) {
|
|
1370
|
+
onlineUsers.delete(username);
|
|
1371
|
+
console.log(`[dashboard] User ${username} went offline`);
|
|
1372
|
+
broadcastPresence({
|
|
1373
|
+
type: 'presence_leave',
|
|
1374
|
+
username,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
console.log(`[dashboard] User ${username} closed tab (${userState.connections.size} remaining)`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
else if (msg.type === 'typing') {
|
|
1384
|
+
// Must have authenticated first
|
|
1385
|
+
if (!clientUsername) {
|
|
1386
|
+
console.warn(`[dashboard] Security: Unauthenticated typing attempt`);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
// Validate typing message comes from authenticated user
|
|
1390
|
+
if (msg.username !== clientUsername) {
|
|
1391
|
+
console.warn(`[dashboard] Security: Typing message username mismatch`);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
// Update last seen
|
|
1395
|
+
const username = clientUsername; // Narrow type for TypeScript
|
|
1396
|
+
const userState = onlineUsers.get(username);
|
|
1397
|
+
if (userState) {
|
|
1398
|
+
userState.info.lastSeen = new Date().toISOString();
|
|
1399
|
+
}
|
|
1400
|
+
// Broadcast typing indicator to all other clients
|
|
1401
|
+
broadcastPresence({
|
|
1402
|
+
type: 'typing',
|
|
1403
|
+
username,
|
|
1404
|
+
avatarUrl: userState?.info.avatarUrl,
|
|
1405
|
+
isTyping: msg.isTyping,
|
|
1406
|
+
}, ws);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
catch (err) {
|
|
1410
|
+
console.error('[dashboard] Invalid presence message:', err);
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
ws.on('error', (err) => {
|
|
1414
|
+
console.error('[dashboard] Presence WebSocket client error:', err);
|
|
1415
|
+
});
|
|
1416
|
+
ws.on('close', () => {
|
|
1417
|
+
// Clean up on disconnect with multi-tab support
|
|
1418
|
+
if (clientUsername) {
|
|
1419
|
+
const userState = onlineUsers.get(clientUsername);
|
|
1420
|
+
if (userState) {
|
|
1421
|
+
userState.connections.delete(ws);
|
|
1422
|
+
// Only broadcast leave if no more connections
|
|
1423
|
+
if (userState.connections.size === 0) {
|
|
1424
|
+
onlineUsers.delete(clientUsername);
|
|
1425
|
+
console.log(`[dashboard] User ${clientUsername} disconnected`);
|
|
1426
|
+
broadcastPresence({
|
|
1427
|
+
type: 'presence_leave',
|
|
1428
|
+
username: clientUsername,
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
else {
|
|
1432
|
+
console.log(`[dashboard] User ${clientUsername} closed connection (${userState.connections.size} remaining)`);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
677
1438
|
app.get('/api/data', (req, res) => {
|
|
678
1439
|
getAllData().then((data) => res.json(data)).catch((err) => {
|
|
679
1440
|
console.error('Failed to fetch dashboard data', err);
|
|
680
1441
|
res.status(500).json({ error: 'Failed to load data' });
|
|
681
1442
|
});
|
|
682
1443
|
});
|
|
1444
|
+
// ===== Health Check API =====
|
|
1445
|
+
/**
|
|
1446
|
+
* GET /health - Health check endpoint for monitoring
|
|
1447
|
+
* Returns 200 if the daemon is healthy
|
|
1448
|
+
*/
|
|
1449
|
+
app.get('/health', async (req, res) => {
|
|
1450
|
+
const uptime = process.uptime();
|
|
1451
|
+
const memUsage = process.memoryUsage();
|
|
1452
|
+
const socketExists = fs.existsSync(socketPath);
|
|
1453
|
+
// Check relay client connectivity (check if default Dashboard client is connected)
|
|
1454
|
+
const defaultClient = relayClients.get('Dashboard');
|
|
1455
|
+
const relayConnected = defaultClient?.state === 'READY';
|
|
1456
|
+
// If socket doesn't exist, daemon may not be running properly
|
|
1457
|
+
if (!socketExists) {
|
|
1458
|
+
return res.status(503).json({
|
|
1459
|
+
status: 'unhealthy',
|
|
1460
|
+
reason: 'Relay socket not found',
|
|
1461
|
+
uptime,
|
|
1462
|
+
memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
res.json({
|
|
1466
|
+
status: 'healthy',
|
|
1467
|
+
uptime,
|
|
1468
|
+
memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
|
|
1469
|
+
relayConnected,
|
|
1470
|
+
websocketClients: wss.clients.size,
|
|
1471
|
+
});
|
|
1472
|
+
});
|
|
1473
|
+
/**
|
|
1474
|
+
* GET /api/health - Alternative health endpoint (same as /health)
|
|
1475
|
+
*/
|
|
1476
|
+
app.get('/api/health', async (req, res) => {
|
|
1477
|
+
const uptime = process.uptime();
|
|
1478
|
+
const memUsage = process.memoryUsage();
|
|
1479
|
+
const socketExists = fs.existsSync(socketPath);
|
|
1480
|
+
const defaultClient = relayClients.get('Dashboard');
|
|
1481
|
+
const relayConnected = defaultClient?.state === 'READY';
|
|
1482
|
+
if (!socketExists) {
|
|
1483
|
+
return res.status(503).json({
|
|
1484
|
+
status: 'unhealthy',
|
|
1485
|
+
reason: 'Relay socket not found',
|
|
1486
|
+
uptime,
|
|
1487
|
+
memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
res.json({
|
|
1491
|
+
status: 'healthy',
|
|
1492
|
+
uptime,
|
|
1493
|
+
memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
|
|
1494
|
+
relayConnected,
|
|
1495
|
+
websocketClients: wss.clients.size,
|
|
1496
|
+
});
|
|
1497
|
+
});
|
|
683
1498
|
// ===== Metrics API =====
|
|
684
1499
|
/**
|
|
685
1500
|
* GET /api/metrics - JSON format metrics for dashboard
|
|
@@ -749,6 +1564,30 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
749
1564
|
res.status(500).send('# Error computing metrics\n');
|
|
750
1565
|
}
|
|
751
1566
|
});
|
|
1567
|
+
// ===== File Search API =====
|
|
1568
|
+
/**
|
|
1569
|
+
* GET /api/files - Search for files in the repository
|
|
1570
|
+
* Query params:
|
|
1571
|
+
* - q: Search query (file path pattern)
|
|
1572
|
+
* - limit: Max number of results (default 15)
|
|
1573
|
+
*
|
|
1574
|
+
* This endpoint searches for files in the project root directory
|
|
1575
|
+
* to support @-file autocomplete in the message composer.
|
|
1576
|
+
*/
|
|
1577
|
+
app.get('/api/files', async (req, res) => {
|
|
1578
|
+
const query = req.query.q || '';
|
|
1579
|
+
const limit = Math.min(parseInt(req.query.limit, 10) || 15, 50);
|
|
1580
|
+
// Get project root (parent of dataDir, or use projectRoot if available)
|
|
1581
|
+
const searchRoot = options.projectRoot || path.dirname(dataDir);
|
|
1582
|
+
try {
|
|
1583
|
+
const results = await searchFiles(searchRoot, query, limit);
|
|
1584
|
+
res.json({ files: results, query, searchRoot: path.basename(searchRoot) });
|
|
1585
|
+
}
|
|
1586
|
+
catch (err) {
|
|
1587
|
+
console.error('[api] File search error:', err);
|
|
1588
|
+
res.status(500).json({ error: 'Failed to search files', files: [] });
|
|
1589
|
+
}
|
|
1590
|
+
});
|
|
752
1591
|
// Bridge API endpoint - returns multi-project data
|
|
753
1592
|
// This is a placeholder that returns empty data when not in bridge mode
|
|
754
1593
|
// The actual bridge data comes from MultiProjectClient when running `agent-relay bridge`
|
|
@@ -774,10 +1613,300 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
774
1613
|
res.status(500).json({ error: 'Failed to load bridge data' });
|
|
775
1614
|
}
|
|
776
1615
|
});
|
|
1616
|
+
// ===== Conversation History API =====
|
|
1617
|
+
/**
|
|
1618
|
+
* GET /api/history/sessions - List all sessions with filters
|
|
1619
|
+
* Query params:
|
|
1620
|
+
* - agent: Filter by agent name
|
|
1621
|
+
* - since: Filter sessions started after this timestamp (ms)
|
|
1622
|
+
* - limit: Max number of sessions (default 50)
|
|
1623
|
+
*/
|
|
1624
|
+
app.get('/api/history/sessions', async (req, res) => {
|
|
1625
|
+
if (!storage) {
|
|
1626
|
+
return res.status(503).json({ error: 'Storage not configured' });
|
|
1627
|
+
}
|
|
1628
|
+
try {
|
|
1629
|
+
const query = {};
|
|
1630
|
+
if (req.query.agent && typeof req.query.agent === 'string') {
|
|
1631
|
+
query.agentName = req.query.agent;
|
|
1632
|
+
}
|
|
1633
|
+
if (req.query.since) {
|
|
1634
|
+
query.since = parseInt(req.query.since, 10);
|
|
1635
|
+
}
|
|
1636
|
+
query.limit = req.query.limit ? parseInt(req.query.limit, 10) : 50;
|
|
1637
|
+
const sessions = storage.getSessions
|
|
1638
|
+
? await storage.getSessions(query)
|
|
1639
|
+
: [];
|
|
1640
|
+
const result = sessions.map(s => ({
|
|
1641
|
+
id: s.id,
|
|
1642
|
+
agentName: s.agentName,
|
|
1643
|
+
cli: s.cli,
|
|
1644
|
+
startedAt: new Date(s.startedAt).toISOString(),
|
|
1645
|
+
endedAt: s.endedAt ? new Date(s.endedAt).toISOString() : undefined,
|
|
1646
|
+
duration: formatDuration(s.startedAt, s.endedAt),
|
|
1647
|
+
messageCount: s.messageCount,
|
|
1648
|
+
summary: s.summary,
|
|
1649
|
+
isActive: !s.endedAt,
|
|
1650
|
+
closedBy: s.closedBy,
|
|
1651
|
+
}));
|
|
1652
|
+
res.json({ sessions: result });
|
|
1653
|
+
}
|
|
1654
|
+
catch (err) {
|
|
1655
|
+
console.error('Failed to fetch sessions', err);
|
|
1656
|
+
res.status(500).json({ error: 'Failed to fetch sessions' });
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
/**
|
|
1660
|
+
* GET /api/history/messages - Get messages with filters
|
|
1661
|
+
* Query params:
|
|
1662
|
+
* - from: Filter by sender
|
|
1663
|
+
* - to: Filter by recipient
|
|
1664
|
+
* - thread: Filter by thread ID
|
|
1665
|
+
* - since: Filter messages after this timestamp (ms)
|
|
1666
|
+
* - limit: Max number of messages (default 100)
|
|
1667
|
+
* - order: 'asc' or 'desc' (default 'desc')
|
|
1668
|
+
* - search: Search in message body (basic substring match)
|
|
1669
|
+
*/
|
|
1670
|
+
app.get('/api/history/messages', async (req, res) => {
|
|
1671
|
+
if (!storage) {
|
|
1672
|
+
return res.status(503).json({ error: 'Storage not configured' });
|
|
1673
|
+
}
|
|
1674
|
+
try {
|
|
1675
|
+
const query = {};
|
|
1676
|
+
if (req.query.from && typeof req.query.from === 'string') {
|
|
1677
|
+
query.from = req.query.from;
|
|
1678
|
+
}
|
|
1679
|
+
if (req.query.to && typeof req.query.to === 'string') {
|
|
1680
|
+
query.to = req.query.to;
|
|
1681
|
+
}
|
|
1682
|
+
if (req.query.thread && typeof req.query.thread === 'string') {
|
|
1683
|
+
query.thread = req.query.thread;
|
|
1684
|
+
}
|
|
1685
|
+
if (req.query.since) {
|
|
1686
|
+
query.sinceTs = parseInt(req.query.since, 10);
|
|
1687
|
+
}
|
|
1688
|
+
query.limit = req.query.limit ? parseInt(req.query.limit, 10) : 100;
|
|
1689
|
+
query.order = req.query.order || 'desc';
|
|
1690
|
+
let messages = await storage.getMessages(query);
|
|
1691
|
+
// Filter out messages from/to internal system agents (e.g., __spawner__)
|
|
1692
|
+
messages = messages.filter(m => !isInternalAgent(m.from) && !isInternalAgent(m.to));
|
|
1693
|
+
// Client-side search filter (basic substring match)
|
|
1694
|
+
const searchTerm = req.query.search;
|
|
1695
|
+
if (searchTerm && searchTerm.trim()) {
|
|
1696
|
+
const lowerSearch = searchTerm.toLowerCase();
|
|
1697
|
+
messages = messages.filter(m => m.body.toLowerCase().includes(lowerSearch) ||
|
|
1698
|
+
m.from.toLowerCase().includes(lowerSearch) ||
|
|
1699
|
+
m.to.toLowerCase().includes(lowerSearch));
|
|
1700
|
+
}
|
|
1701
|
+
const result = messages.map(m => ({
|
|
1702
|
+
id: m.id,
|
|
1703
|
+
from: m.from,
|
|
1704
|
+
to: m.to,
|
|
1705
|
+
content: m.body,
|
|
1706
|
+
timestamp: new Date(m.ts).toISOString(),
|
|
1707
|
+
thread: m.thread,
|
|
1708
|
+
isBroadcast: m.is_broadcast,
|
|
1709
|
+
isUrgent: m.is_urgent,
|
|
1710
|
+
status: m.status,
|
|
1711
|
+
}));
|
|
1712
|
+
res.json({ messages: result });
|
|
1713
|
+
}
|
|
1714
|
+
catch (err) {
|
|
1715
|
+
console.error('Failed to fetch messages', err);
|
|
1716
|
+
res.status(500).json({ error: 'Failed to fetch messages' });
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
/**
|
|
1720
|
+
* GET /api/history/conversations - Get unique conversations (agent pairs)
|
|
1721
|
+
* Returns list of agent pairs that have exchanged messages
|
|
1722
|
+
*/
|
|
1723
|
+
app.get('/api/history/conversations', async (req, res) => {
|
|
1724
|
+
if (!storage) {
|
|
1725
|
+
return res.status(503).json({ error: 'Storage not configured' });
|
|
1726
|
+
}
|
|
1727
|
+
try {
|
|
1728
|
+
// Get all messages to build conversation list
|
|
1729
|
+
const messages = await storage.getMessages({ limit: 1000, order: 'desc' });
|
|
1730
|
+
// Build unique conversation pairs
|
|
1731
|
+
const conversationMap = new Map();
|
|
1732
|
+
for (const msg of messages) {
|
|
1733
|
+
// Skip broadcasts for conversation pairing
|
|
1734
|
+
if (msg.to === '*' || msg.is_broadcast)
|
|
1735
|
+
continue;
|
|
1736
|
+
// Skip messages from/to internal system agents (e.g., __spawner__)
|
|
1737
|
+
if (isInternalAgent(msg.from) || isInternalAgent(msg.to))
|
|
1738
|
+
continue;
|
|
1739
|
+
// Create normalized key (sorted participants)
|
|
1740
|
+
const participants = [msg.from, msg.to].sort();
|
|
1741
|
+
const key = participants.join(':');
|
|
1742
|
+
const existing = conversationMap.get(key);
|
|
1743
|
+
if (existing) {
|
|
1744
|
+
existing.messageCount++;
|
|
1745
|
+
}
|
|
1746
|
+
else {
|
|
1747
|
+
conversationMap.set(key, {
|
|
1748
|
+
participants,
|
|
1749
|
+
lastMessage: msg.body.substring(0, 100),
|
|
1750
|
+
lastTimestamp: new Date(msg.ts).toISOString(),
|
|
1751
|
+
messageCount: 1,
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
// Convert to array sorted by last timestamp
|
|
1756
|
+
const conversations = Array.from(conversationMap.values())
|
|
1757
|
+
.sort((a, b) => new Date(b.lastTimestamp).getTime() - new Date(a.lastTimestamp).getTime());
|
|
1758
|
+
res.json({ conversations });
|
|
1759
|
+
}
|
|
1760
|
+
catch (err) {
|
|
1761
|
+
console.error('Failed to fetch conversations', err);
|
|
1762
|
+
res.status(500).json({ error: 'Failed to fetch conversations' });
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
/**
|
|
1766
|
+
* GET /api/history/message/:id - Get a single message by ID
|
|
1767
|
+
*/
|
|
1768
|
+
app.get('/api/history/message/:id', async (req, res) => {
|
|
1769
|
+
if (!storage) {
|
|
1770
|
+
return res.status(503).json({ error: 'Storage not configured' });
|
|
1771
|
+
}
|
|
1772
|
+
try {
|
|
1773
|
+
const { id } = req.params;
|
|
1774
|
+
const message = storage.getMessageById
|
|
1775
|
+
? await storage.getMessageById(id)
|
|
1776
|
+
: null;
|
|
1777
|
+
if (!message) {
|
|
1778
|
+
return res.status(404).json({ error: 'Message not found' });
|
|
1779
|
+
}
|
|
1780
|
+
res.json({
|
|
1781
|
+
id: message.id,
|
|
1782
|
+
from: message.from,
|
|
1783
|
+
to: message.to,
|
|
1784
|
+
content: message.body,
|
|
1785
|
+
timestamp: new Date(message.ts).toISOString(),
|
|
1786
|
+
thread: message.thread,
|
|
1787
|
+
isBroadcast: message.is_broadcast,
|
|
1788
|
+
isUrgent: message.is_urgent,
|
|
1789
|
+
status: message.status,
|
|
1790
|
+
data: message.data,
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
catch (err) {
|
|
1794
|
+
console.error('Failed to fetch message', err);
|
|
1795
|
+
res.status(500).json({ error: 'Failed to fetch message' });
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
/**
|
|
1799
|
+
* GET /api/history/stats - Get storage statistics
|
|
1800
|
+
*/
|
|
1801
|
+
app.get('/api/history/stats', async (req, res) => {
|
|
1802
|
+
if (!storage) {
|
|
1803
|
+
return res.status(503).json({ error: 'Storage not configured' });
|
|
1804
|
+
}
|
|
1805
|
+
try {
|
|
1806
|
+
// Get stats from SQLite adapter if available
|
|
1807
|
+
if (storage instanceof SqliteStorageAdapter) {
|
|
1808
|
+
const stats = await storage.getStats();
|
|
1809
|
+
const sessions = await storage.getSessions({ limit: 1000 });
|
|
1810
|
+
// Calculate additional stats
|
|
1811
|
+
const activeSessions = sessions.filter(s => !s.endedAt).length;
|
|
1812
|
+
const uniqueAgents = new Set(sessions.map(s => s.agentName)).size;
|
|
1813
|
+
res.json({
|
|
1814
|
+
messageCount: stats.messageCount,
|
|
1815
|
+
sessionCount: stats.sessionCount,
|
|
1816
|
+
activeSessions,
|
|
1817
|
+
uniqueAgents,
|
|
1818
|
+
oldestMessageDate: stats.oldestMessageTs
|
|
1819
|
+
? new Date(stats.oldestMessageTs).toISOString()
|
|
1820
|
+
: null,
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
else {
|
|
1824
|
+
// Basic stats for other adapters
|
|
1825
|
+
const messages = await storage.getMessages({ limit: 1 });
|
|
1826
|
+
res.json({
|
|
1827
|
+
messageCount: messages.length > 0 ? 'unknown' : 0,
|
|
1828
|
+
sessionCount: 'unknown',
|
|
1829
|
+
activeSessions: 'unknown',
|
|
1830
|
+
uniqueAgents: 'unknown',
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
catch (err) {
|
|
1835
|
+
console.error('Failed to fetch stats', err);
|
|
1836
|
+
res.status(500).json({ error: 'Failed to fetch stats' });
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
// ===== Agent Logs API =====
|
|
1840
|
+
/**
|
|
1841
|
+
* GET /api/logs/:name - Get historical logs for a spawned agent
|
|
1842
|
+
* Query params:
|
|
1843
|
+
* - limit: Max lines to return (default 500)
|
|
1844
|
+
* - raw: If 'true', return raw output instead of cleaned lines
|
|
1845
|
+
*/
|
|
1846
|
+
app.get('/api/logs/:name', (req, res) => {
|
|
1847
|
+
if (!spawner) {
|
|
1848
|
+
return res.status(503).json({ error: 'Spawner not enabled' });
|
|
1849
|
+
}
|
|
1850
|
+
const { name } = req.params;
|
|
1851
|
+
const limit = req.query.limit ? parseInt(req.query.limit, 10) : 500;
|
|
1852
|
+
const raw = req.query.raw === 'true';
|
|
1853
|
+
// Check if worker exists
|
|
1854
|
+
if (!spawner.hasWorker(name)) {
|
|
1855
|
+
return res.status(404).json({ error: `Agent ${name} not found` });
|
|
1856
|
+
}
|
|
1857
|
+
try {
|
|
1858
|
+
if (raw) {
|
|
1859
|
+
const output = spawner.getWorkerRawOutput(name);
|
|
1860
|
+
res.json({
|
|
1861
|
+
name,
|
|
1862
|
+
raw: true,
|
|
1863
|
+
output: output || '',
|
|
1864
|
+
timestamp: new Date().toISOString(),
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
else {
|
|
1868
|
+
const lines = spawner.getWorkerOutput(name, limit);
|
|
1869
|
+
res.json({
|
|
1870
|
+
name,
|
|
1871
|
+
raw: false,
|
|
1872
|
+
lines: lines || [],
|
|
1873
|
+
lineCount: lines?.length || 0,
|
|
1874
|
+
timestamp: new Date().toISOString(),
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
catch (err) {
|
|
1879
|
+
console.error(`Failed to get logs for ${name}:`, err);
|
|
1880
|
+
res.status(500).json({ error: 'Failed to get logs' });
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
/**
|
|
1884
|
+
* GET /api/logs - List all agents with available logs
|
|
1885
|
+
*/
|
|
1886
|
+
app.get('/api/logs', (req, res) => {
|
|
1887
|
+
if (!spawner) {
|
|
1888
|
+
return res.status(503).json({ error: 'Spawner not enabled' });
|
|
1889
|
+
}
|
|
1890
|
+
try {
|
|
1891
|
+
const workers = spawner.getActiveWorkers();
|
|
1892
|
+
const agents = workers.map(w => ({
|
|
1893
|
+
name: w.name,
|
|
1894
|
+
cli: w.cli,
|
|
1895
|
+
pid: w.pid,
|
|
1896
|
+
spawnedAt: new Date(w.spawnedAt).toISOString(),
|
|
1897
|
+
hasLogs: true,
|
|
1898
|
+
}));
|
|
1899
|
+
res.json({ agents });
|
|
1900
|
+
}
|
|
1901
|
+
catch (err) {
|
|
1902
|
+
console.error('Failed to list agents with logs:', err);
|
|
1903
|
+
res.status(500).json({ error: 'Failed to list agents' });
|
|
1904
|
+
}
|
|
1905
|
+
});
|
|
777
1906
|
// ===== Agent Spawn API =====
|
|
778
1907
|
/**
|
|
779
1908
|
* POST /api/spawn - Spawn a new agent
|
|
780
|
-
* Body: { name: string, cli?: string, task?: string, team?: string }
|
|
1909
|
+
* Body: { name: string, cli?: string, task?: string, team?: string, shadowMode?, shadowAgent?, shadowOf?, shadowTriggers?, shadowSpeakOn? }
|
|
781
1910
|
*/
|
|
782
1911
|
app.post('/api/spawn', async (req, res) => {
|
|
783
1912
|
if (!spawner) {
|
|
@@ -786,7 +1915,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
786
1915
|
error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
|
|
787
1916
|
});
|
|
788
1917
|
}
|
|
789
|
-
const { name, cli = 'claude', task = '', team } = req.body;
|
|
1918
|
+
const { name, cli = 'claude', task = '', team, shadowMode, shadowAgent, shadowOf, shadowTriggers, shadowSpeakOn, } = req.body;
|
|
790
1919
|
if (!name || typeof name !== 'string') {
|
|
791
1920
|
return res.status(400).json({
|
|
792
1921
|
success: false,
|
|
@@ -799,6 +1928,11 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
799
1928
|
cli,
|
|
800
1929
|
task,
|
|
801
1930
|
team: team || undefined, // Optional team name
|
|
1931
|
+
shadowMode,
|
|
1932
|
+
shadowAgent,
|
|
1933
|
+
shadowOf,
|
|
1934
|
+
shadowTriggers,
|
|
1935
|
+
shadowSpeakOn,
|
|
802
1936
|
};
|
|
803
1937
|
const result = await spawner.spawn(request);
|
|
804
1938
|
if (result.success) {
|
|
@@ -816,6 +1950,95 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
816
1950
|
});
|
|
817
1951
|
}
|
|
818
1952
|
});
|
|
1953
|
+
/**
|
|
1954
|
+
* POST /api/spawn/architect - Spawn an Architect agent for bridge mode
|
|
1955
|
+
* Body: { cli?: string }
|
|
1956
|
+
*/
|
|
1957
|
+
app.post('/api/spawn/architect', async (req, res) => {
|
|
1958
|
+
if (!spawner) {
|
|
1959
|
+
return res.status(503).json({
|
|
1960
|
+
success: false,
|
|
1961
|
+
error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
const { cli = 'claude' } = req.body;
|
|
1965
|
+
// Check if Architect already exists
|
|
1966
|
+
const activeWorkers = spawner.getActiveWorkers();
|
|
1967
|
+
if (activeWorkers.some(w => w.name.toLowerCase() === 'architect')) {
|
|
1968
|
+
return res.status(409).json({
|
|
1969
|
+
success: false,
|
|
1970
|
+
error: 'Architect agent already running',
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
// Get bridge state for project context
|
|
1974
|
+
const bridgeStatePath = path.join(dataDir, 'bridge-state.json');
|
|
1975
|
+
let projectContext = 'No bridge projects connected.';
|
|
1976
|
+
if (fs.existsSync(bridgeStatePath)) {
|
|
1977
|
+
try {
|
|
1978
|
+
const bridgeState = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8'));
|
|
1979
|
+
if (bridgeState.projects && bridgeState.projects.length > 0) {
|
|
1980
|
+
projectContext = bridgeState.projects
|
|
1981
|
+
.map((p) => `- ${p.id}: ${p.path} (Lead: ${p.lead?.name || 'none'})`)
|
|
1982
|
+
.join('\n');
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
catch (e) {
|
|
1986
|
+
console.error('[api] Failed to read bridge state:', e);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
// Build the architect prompt
|
|
1990
|
+
const architectPrompt = `You are the Architect, a cross-project coordinator overseeing multiple codebases.
|
|
1991
|
+
|
|
1992
|
+
## Connected Projects
|
|
1993
|
+
${projectContext}
|
|
1994
|
+
|
|
1995
|
+
## Your Role
|
|
1996
|
+
- Coordinate high-level work across all projects
|
|
1997
|
+
- Assign tasks to project leads
|
|
1998
|
+
- Ensure consistency and resolve cross-project dependencies
|
|
1999
|
+
- Review overall architecture decisions
|
|
2000
|
+
|
|
2001
|
+
## Cross-Project Messaging
|
|
2002
|
+
|
|
2003
|
+
Use this syntax to message agents in specific projects:
|
|
2004
|
+
|
|
2005
|
+
\`\`\`
|
|
2006
|
+
->relay:project-id:AgentName <<<
|
|
2007
|
+
Your message to this agent>>>
|
|
2008
|
+
|
|
2009
|
+
->relay:project-id:* <<<
|
|
2010
|
+
Broadcast to all agents in a project>>>
|
|
2011
|
+
|
|
2012
|
+
->relay:*:* <<<
|
|
2013
|
+
Broadcast to ALL agents in ALL projects>>>
|
|
2014
|
+
\`\`\`
|
|
2015
|
+
|
|
2016
|
+
## Getting Started
|
|
2017
|
+
1. Check in with each project lead to understand current status
|
|
2018
|
+
2. Identify cross-project dependencies
|
|
2019
|
+
3. Coordinate work across teams
|
|
2020
|
+
|
|
2021
|
+
Start by greeting the project leads and asking for status updates.`;
|
|
2022
|
+
try {
|
|
2023
|
+
const result = await spawner.spawn({
|
|
2024
|
+
name: 'Architect',
|
|
2025
|
+
cli,
|
|
2026
|
+
task: architectPrompt,
|
|
2027
|
+
});
|
|
2028
|
+
if (result.success) {
|
|
2029
|
+
broadcastData().catch(() => { });
|
|
2030
|
+
}
|
|
2031
|
+
res.json(result);
|
|
2032
|
+
}
|
|
2033
|
+
catch (err) {
|
|
2034
|
+
console.error('[api] Architect spawn error:', err);
|
|
2035
|
+
res.status(500).json({
|
|
2036
|
+
success: false,
|
|
2037
|
+
name: 'Architect',
|
|
2038
|
+
error: err.message,
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
});
|
|
819
2042
|
/**
|
|
820
2043
|
* GET /api/spawned - List active spawned agents
|
|
821
2044
|
*/
|
|
@@ -878,7 +2101,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
878
2101
|
if (fs.existsSync(dataDir)) {
|
|
879
2102
|
console.log(`Watching ${dataDir} for changes...`);
|
|
880
2103
|
fs.watch(dataDir, { recursive: true }, (eventType, filename) => {
|
|
881
|
-
if (filename && (filename.endsWith('inbox.md') || filename.endsWith('team.json') || filename.endsWith('agents.json'))) {
|
|
2104
|
+
if (filename && (filename.endsWith('inbox.md') || filename.endsWith('team.json') || filename.endsWith('agents.json') || filename.endsWith('processing-state.json'))) {
|
|
882
2105
|
// Debounce
|
|
883
2106
|
if (fsWait)
|
|
884
2107
|
return;
|