@jx0/agency 0.2.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/README.md +272 -0
- package/bin/agency.js +2 -0
- package/dashboard/out/404.html +1 -0
- package/dashboard/out/_next/static/chunks/255-67e8754147461423.js +1 -0
- package/dashboard/out/_next/static/chunks/4bd1b696-c023c6e3521b1417.js +1 -0
- package/dashboard/out/_next/static/chunks/app/_not-found/page-ad40673d821037f6.js +1 -0
- package/dashboard/out/_next/static/chunks/app/layout-056f12675e691d12.js +1 -0
- package/dashboard/out/_next/static/chunks/app/page-80f01fdbb09b43c8.js +1 -0
- package/dashboard/out/_next/static/chunks/framework-de98b93a850cfc71.js +1 -0
- package/dashboard/out/_next/static/chunks/main-1a0dcce460eb61ce.js +1 -0
- package/dashboard/out/_next/static/chunks/main-app-1d848b791b823fa6.js +1 -0
- package/dashboard/out/_next/static/chunks/pages/_app-7d307437aca18ad4.js +1 -0
- package/dashboard/out/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js +1 -0
- package/dashboard/out/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/dashboard/out/_next/static/chunks/webpack-4e6bf084ac60582b.js +1 -0
- package/dashboard/out/_next/static/css/27d1ea794f04e96a.css +1 -0
- package/dashboard/out/_next/static/pU1nwWH_dNUOCI8y4nl3C/_buildManifest.js +1 -0
- package/dashboard/out/_next/static/pU1nwWH_dNUOCI8y4nl3C/_ssgManifest.js +1 -0
- package/dashboard/out/index.html +1 -0
- package/dashboard/out/index.txt +19 -0
- package/docs/images/agency_cli_ps.png +0 -0
- package/docs/images/agency_ui_ai_prodivder_settings.png +0 -0
- package/docs/images/agency_ui_aws_settings.png +0 -0
- package/docs/images/agency_ui_identity_settings.png +0 -0
- package/docs/images/agency_ui_mission_control.png +0 -0
- package/docs/images/agent_ui_agent_config.png +0 -0
- package/package.json +31 -0
- package/src/api/db/client.ts +16 -0
- package/src/api/db/migrate.ts +37 -0
- package/src/api/db/migrations/001_initial.ts +193 -0
- package/src/api/db/migrations/002_configs.ts +76 -0
- package/src/api/db/migrations/003_settings_columns.ts +13 -0
- package/src/api/db/seed.ts +142 -0
- package/src/api/db/types.ts +126 -0
- package/src/api/index.ts +73 -0
- package/src/api/lib/activity.ts +13 -0
- package/src/api/lib/fleet-sync.ts +156 -0
- package/src/api/lib/mentions.ts +59 -0
- package/src/api/lib/processes.ts +45 -0
- package/src/api/lib/resolve-agent.ts +5 -0
- package/src/api/lib/tunnels.ts +99 -0
- package/src/api/routes/activities.ts +27 -0
- package/src/api/routes/agents.ts +311 -0
- package/src/api/routes/documents.ts +41 -0
- package/src/api/routes/knowledge.ts +60 -0
- package/src/api/routes/messages.ts +54 -0
- package/src/api/routes/notifications.ts +40 -0
- package/src/api/routes/oauth.ts +171 -0
- package/src/api/routes/role-configs.ts +71 -0
- package/src/api/routes/settings.ts +94 -0
- package/src/api/routes/skills.ts +76 -0
- package/src/api/routes/tasks.ts +154 -0
- package/src/cli/commands/config.ts +42 -0
- package/src/cli/commands/daemon.ts +173 -0
- package/src/cli/commands/doc.ts +47 -0
- package/src/cli/commands/init.ts +105 -0
- package/src/cli/commands/learn.ts +51 -0
- package/src/cli/commands/logs.ts +31 -0
- package/src/cli/commands/msg.ts +18 -0
- package/src/cli/commands/ps.ts +19 -0
- package/src/cli/commands/recall.ts +18 -0
- package/src/cli/commands/skills.ts +66 -0
- package/src/cli/commands/ssh.ts +68 -0
- package/src/cli/commands/start.ts +14 -0
- package/src/cli/commands/status.ts +33 -0
- package/src/cli/commands/stop.ts +11 -0
- package/src/cli/commands/tasks.ts +150 -0
- package/src/cli/index.ts +70 -0
- package/src/cli/lib/api.ts +16 -0
- package/src/cli/lib/config.ts +5 -0
- package/src/cli/lib/find-root.ts +32 -0
- package/src/cli/lib/prompt.ts +20 -0
- package/src/daemon.ts +83 -0
- package/src/templates/implementer/agents-config.md +44 -0
- package/src/templates/implementer/agents.md +32 -0
- package/src/templates/implementer/heartbeat.md +47 -0
- package/src/templates/implementer/tools.md +33 -0
- package/src/templates/orchestrator/agents-config.md +44 -0
- package/src/templates/orchestrator/agents.md +27 -0
- package/src/templates/orchestrator/heartbeat.md +40 -0
- package/src/templates/orchestrator/tools.md +40 -0
- package/src/templates/shared/environment.md +20 -0
- package/src/templates/shared/memory.md +20 -0
- package/src/templates/shared/soul.md +26 -0
- package/src/templates/shared/user.md +12 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { db } from "../db/client.js";
|
|
4
|
+
|
|
5
|
+
function resolveFleetPath(): string {
|
|
6
|
+
return process.env.FLEET_PATH ?? path.resolve(process.cwd(), ".agency", "fleet.json");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let writeLock = false;
|
|
10
|
+
let watcherTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
11
|
+
|
|
12
|
+
interface FleetAgent {
|
|
13
|
+
role: string;
|
|
14
|
+
location?: string;
|
|
15
|
+
host?: string;
|
|
16
|
+
slackBotToken?: string;
|
|
17
|
+
slackAppToken?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Fleet {
|
|
21
|
+
agents: Record<string, FleetAgent>;
|
|
22
|
+
[key: string]: any;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readFleet(): Fleet {
|
|
26
|
+
const fleetPath = resolveFleetPath();
|
|
27
|
+
if (!fs.existsSync(fleetPath)) {
|
|
28
|
+
return { agents: {} };
|
|
29
|
+
}
|
|
30
|
+
return JSON.parse(fs.readFileSync(fleetPath, "utf-8"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeFleet(fleet: Fleet): void {
|
|
34
|
+
const fleetPath = resolveFleetPath();
|
|
35
|
+
fs.writeFileSync(fleetPath, JSON.stringify(fleet, null, 2) + "\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function writeAgentToFleet(
|
|
39
|
+
name: string,
|
|
40
|
+
config: FleetAgent
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
writeLock = true;
|
|
43
|
+
try {
|
|
44
|
+
const fleet = readFleet();
|
|
45
|
+
fleet.agents[name] = config;
|
|
46
|
+
writeFleet(fleet);
|
|
47
|
+
} finally {
|
|
48
|
+
writeLock = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function removeAgentFromFleet(name: string): Promise<void> {
|
|
53
|
+
writeLock = true;
|
|
54
|
+
try {
|
|
55
|
+
const fleet = readFleet();
|
|
56
|
+
delete fleet.agents[name];
|
|
57
|
+
writeFleet(fleet);
|
|
58
|
+
} finally {
|
|
59
|
+
writeLock = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function reconcileDbFromFleet(): Promise<void> {
|
|
64
|
+
const fleet = readFleet();
|
|
65
|
+
const agents = fleet.agents ?? {};
|
|
66
|
+
|
|
67
|
+
for (const [name, config] of Object.entries(agents)) {
|
|
68
|
+
const existing = await db
|
|
69
|
+
.selectFrom("agents")
|
|
70
|
+
.select("id")
|
|
71
|
+
.where("name", "=", name)
|
|
72
|
+
.executeTakeFirst();
|
|
73
|
+
|
|
74
|
+
if (existing) {
|
|
75
|
+
await db
|
|
76
|
+
.updateTable("agents")
|
|
77
|
+
.where("id", "=", existing.id)
|
|
78
|
+
.set({
|
|
79
|
+
role: config.role,
|
|
80
|
+
location: config.location ?? null,
|
|
81
|
+
slack_bot_token: config.slackBotToken ?? null,
|
|
82
|
+
slack_app_token: config.slackAppToken ?? null,
|
|
83
|
+
updated_at: new Date().toISOString(),
|
|
84
|
+
})
|
|
85
|
+
.execute();
|
|
86
|
+
} else {
|
|
87
|
+
await db
|
|
88
|
+
.insertInto("agents")
|
|
89
|
+
.values({
|
|
90
|
+
id: crypto.randomUUID(),
|
|
91
|
+
name,
|
|
92
|
+
role: config.role,
|
|
93
|
+
location: config.location ?? null,
|
|
94
|
+
slack_bot_token: config.slackBotToken ?? null,
|
|
95
|
+
slack_app_token: config.slackAppToken ?? null,
|
|
96
|
+
status: "idle",
|
|
97
|
+
current_task: null,
|
|
98
|
+
session_key: `agent:${name}:main`,
|
|
99
|
+
})
|
|
100
|
+
.execute();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Ensure "human" agent exists for dashboard task creation
|
|
105
|
+
const humanExists = await db
|
|
106
|
+
.selectFrom("agents")
|
|
107
|
+
.select("id")
|
|
108
|
+
.where("name", "=", "human")
|
|
109
|
+
.executeTakeFirst();
|
|
110
|
+
|
|
111
|
+
if (!humanExists) {
|
|
112
|
+
await db
|
|
113
|
+
.insertInto("agents")
|
|
114
|
+
.values({
|
|
115
|
+
id: crypto.randomUUID(),
|
|
116
|
+
name: "human",
|
|
117
|
+
role: "human",
|
|
118
|
+
status: "active",
|
|
119
|
+
current_task: null,
|
|
120
|
+
session_key: "agent:human:main",
|
|
121
|
+
})
|
|
122
|
+
.execute();
|
|
123
|
+
console.log("[fleet-sync] created human agent");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(`[fleet-sync] reconciled ${Object.keys(agents).length} agent(s)`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function regenerateCompose(): Promise<void> {
|
|
130
|
+
// No-op for now — docker compose generation moved to CLI
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function startWatcher(): void {
|
|
134
|
+
const fleetPath = resolveFleetPath();
|
|
135
|
+
if (!fs.existsSync(fleetPath)) {
|
|
136
|
+
console.log("[fleet-sync] no fleet.json found, skipping watcher");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
fs.watch(fleetPath, () => {
|
|
141
|
+
if (writeLock) return;
|
|
142
|
+
if (watcherTimeout) clearTimeout(watcherTimeout);
|
|
143
|
+
watcherTimeout = setTimeout(async () => {
|
|
144
|
+
console.log("[fleet-sync] fleet.json changed externally, reconciling...");
|
|
145
|
+
try {
|
|
146
|
+
await reconcileDbFromFleet();
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error("[fleet-sync] reconcile error:", err);
|
|
149
|
+
}
|
|
150
|
+
}, 500);
|
|
151
|
+
});
|
|
152
|
+
console.log("[fleet-sync] watching fleet.json for changes");
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error("[fleet-sync] could not watch fleet.json:", err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { db } from "../db/client.js";
|
|
2
|
+
|
|
3
|
+
export async function parseMentions(
|
|
4
|
+
content: string,
|
|
5
|
+
excludeAgentId: string
|
|
6
|
+
): Promise<string[]> {
|
|
7
|
+
const mentionRegex = /@(\w+)/g;
|
|
8
|
+
const mentions: string[] = [];
|
|
9
|
+
let match: RegExpExecArray | null;
|
|
10
|
+
while ((match = mentionRegex.exec(content)) !== null) {
|
|
11
|
+
mentions.push(match[1]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (mentions.length === 0) return [];
|
|
15
|
+
|
|
16
|
+
// Handle @all
|
|
17
|
+
if (mentions.includes("all")) {
|
|
18
|
+
const agents = await db
|
|
19
|
+
.selectFrom("agents")
|
|
20
|
+
.where("id", "!=", excludeAgentId)
|
|
21
|
+
.select("id")
|
|
22
|
+
.execute();
|
|
23
|
+
return agents.map((a) => a.id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Resolve named mentions
|
|
27
|
+
const agents = await db
|
|
28
|
+
.selectFrom("agents")
|
|
29
|
+
.where("name", "in", mentions)
|
|
30
|
+
.where("id", "!=", excludeAgentId)
|
|
31
|
+
.select("id")
|
|
32
|
+
.execute();
|
|
33
|
+
return agents.map((a) => a.id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getTaskSubscribers(
|
|
37
|
+
taskId: string,
|
|
38
|
+
excludeAgentId: string
|
|
39
|
+
): Promise<string[]> {
|
|
40
|
+
const assignees = await db
|
|
41
|
+
.selectFrom("task_assignees")
|
|
42
|
+
.where("task_id", "=", taskId)
|
|
43
|
+
.where("agent_id", "!=", excludeAgentId)
|
|
44
|
+
.select("agent_id")
|
|
45
|
+
.execute();
|
|
46
|
+
|
|
47
|
+
const messageAuthors = await db
|
|
48
|
+
.selectFrom("messages")
|
|
49
|
+
.where("task_id", "=", taskId)
|
|
50
|
+
.where("from_agent", "!=", excludeAgentId)
|
|
51
|
+
.select("from_agent")
|
|
52
|
+
.distinct()
|
|
53
|
+
.execute();
|
|
54
|
+
|
|
55
|
+
const ids = new Set<string>();
|
|
56
|
+
for (const a of assignees) ids.add(a.agent_id);
|
|
57
|
+
for (const m of messageAuthors) ids.add(m.from_agent);
|
|
58
|
+
return [...ids];
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Subprocess } from "bun";
|
|
2
|
+
|
|
3
|
+
const localProcesses = new Map<string, Subprocess>();
|
|
4
|
+
|
|
5
|
+
export function startLocal(name: string, role: string): Subprocess {
|
|
6
|
+
const existing = localProcesses.get(name);
|
|
7
|
+
if (existing) {
|
|
8
|
+
existing.kill();
|
|
9
|
+
localProcesses.delete(name);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const proc = Bun.spawn(["bun", "packages/agent/src/index.ts"], {
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
env: {
|
|
15
|
+
...process.env,
|
|
16
|
+
AGENCY_AGENT_NAME: name,
|
|
17
|
+
AGENCY_ROLE: role,
|
|
18
|
+
},
|
|
19
|
+
stdout: "inherit",
|
|
20
|
+
stderr: "inherit",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
localProcesses.set(name, proc);
|
|
24
|
+
|
|
25
|
+
// Clean up map entry when process exits
|
|
26
|
+
proc.exited.then(() => {
|
|
27
|
+
if (localProcesses.get(name) === proc) {
|
|
28
|
+
localProcesses.delete(name);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return proc;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stopLocal(name: string): boolean {
|
|
36
|
+
const proc = localProcesses.get(name);
|
|
37
|
+
if (!proc) return false;
|
|
38
|
+
proc.kill();
|
|
39
|
+
localProcesses.delete(name);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isLocalRunning(name: string): boolean {
|
|
44
|
+
return localProcesses.has(name);
|
|
45
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Subprocess } from "bun";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import { db } from "../db/client.js";
|
|
6
|
+
|
|
7
|
+
const tunnels = new Map<string, Subprocess>();
|
|
8
|
+
|
|
9
|
+
const API_PORT = Number(process.env.PORT ?? 3100);
|
|
10
|
+
|
|
11
|
+
async function getSSHConfig(): Promise<{ keyPath: string; user: string }> {
|
|
12
|
+
const rows = await db
|
|
13
|
+
.selectFrom("settings")
|
|
14
|
+
.where("category", "=", "ssh")
|
|
15
|
+
.selectAll()
|
|
16
|
+
.execute();
|
|
17
|
+
|
|
18
|
+
const settings: Record<string, string> = {};
|
|
19
|
+
for (const r of rows) settings[r.key] = r.value;
|
|
20
|
+
|
|
21
|
+
const user = settings["ssh.user"] || "ubuntu";
|
|
22
|
+
const privateKey = settings["ssh.private_key"] || "";
|
|
23
|
+
|
|
24
|
+
if (!privateKey) {
|
|
25
|
+
throw new Error("SSH private key not configured. Set it in Settings → SSH.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Write key to a temp file (SSH requires a file path)
|
|
29
|
+
const keyDir = path.join(os.tmpdir(), "agency-ssh");
|
|
30
|
+
fs.mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
|
31
|
+
const keyPath = path.join(keyDir, "agent_key");
|
|
32
|
+
fs.writeFileSync(keyPath, privateKey + "\n", { mode: 0o600 });
|
|
33
|
+
|
|
34
|
+
return { keyPath, user };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function startTunnel(name: string, host: string): Promise<void> {
|
|
38
|
+
// Kill existing tunnel if any
|
|
39
|
+
stopTunnel(name);
|
|
40
|
+
|
|
41
|
+
const { keyPath, user } = await getSSHConfig();
|
|
42
|
+
|
|
43
|
+
const args = [
|
|
44
|
+
"ssh",
|
|
45
|
+
"-i", keyPath,
|
|
46
|
+
"-o", "StrictHostKeyChecking=no",
|
|
47
|
+
"-o", "ServerAliveInterval=30",
|
|
48
|
+
"-o", "ServerAliveCountMax=3",
|
|
49
|
+
"-o", "ExitOnForwardFailure=yes",
|
|
50
|
+
"-N", // no command, just tunnel
|
|
51
|
+
"-R", `${API_PORT}:localhost:${API_PORT}`, // reverse tunnel: remote:3100 -> local:3100
|
|
52
|
+
`${user}@${host}`,
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
console.log(`[tunnel] opening reverse tunnel to ${name} (${user}@${host})`);
|
|
56
|
+
|
|
57
|
+
const proc = Bun.spawn(args, {
|
|
58
|
+
stdout: "inherit",
|
|
59
|
+
stderr: "inherit",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
tunnels.set(name, proc);
|
|
63
|
+
|
|
64
|
+
// Auto-restart on disconnect
|
|
65
|
+
proc.exited.then((code) => {
|
|
66
|
+
if (tunnels.get(name) !== proc) return; // already replaced
|
|
67
|
+
tunnels.delete(name);
|
|
68
|
+
console.log(`[tunnel] ${name} exited with code ${code}, reconnecting in 5s...`);
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
// Only restart if no new tunnel was created
|
|
71
|
+
if (!tunnels.has(name)) {
|
|
72
|
+
startTunnel(name, host).catch((err) =>
|
|
73
|
+
console.error(`[tunnel] failed to restart tunnel for ${name}:`, err.message)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}, 5000);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function stopTunnel(name: string): boolean {
|
|
81
|
+
const proc = tunnels.get(name);
|
|
82
|
+
if (!proc) return false;
|
|
83
|
+
proc.kill();
|
|
84
|
+
tunnels.delete(name);
|
|
85
|
+
console.log(`[tunnel] closed tunnel to ${name}`);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isTunnelRunning(name: string): boolean {
|
|
90
|
+
return tunnels.has(name);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function stopAllTunnels(): void {
|
|
94
|
+
for (const [name, proc] of tunnels) {
|
|
95
|
+
proc.kill();
|
|
96
|
+
console.log(`[tunnel] closed tunnel to ${name}`);
|
|
97
|
+
}
|
|
98
|
+
tunnels.clear();
|
|
99
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { db } from "../db/client.js";
|
|
3
|
+
|
|
4
|
+
export const activities = new Hono();
|
|
5
|
+
|
|
6
|
+
activities.get("/", async (c) => {
|
|
7
|
+
const limit = Number(c.req.query("limit") ?? 50);
|
|
8
|
+
const since = c.req.query("since");
|
|
9
|
+
|
|
10
|
+
let q = db
|
|
11
|
+
.selectFrom("activities")
|
|
12
|
+
.innerJoin("agents", "agents.id", "activities.agent_id")
|
|
13
|
+
.select([
|
|
14
|
+
"activities.id", "activities.type", "activities.agent_id",
|
|
15
|
+
"activities.task_id", "activities.summary", "activities.created_at",
|
|
16
|
+
"agents.name as agent_name",
|
|
17
|
+
])
|
|
18
|
+
.orderBy("activities.created_at", "desc")
|
|
19
|
+
.limit(limit);
|
|
20
|
+
|
|
21
|
+
if (since) {
|
|
22
|
+
q = q.where("activities.created_at", ">=", new Date(since));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rows = await q.execute();
|
|
26
|
+
return c.json(rows);
|
|
27
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { db } from "../db/client.js";
|
|
3
|
+
import { resolveAgent } from "../lib/resolve-agent.js";
|
|
4
|
+
import {
|
|
5
|
+
writeAgentToFleet,
|
|
6
|
+
removeAgentFromFleet,
|
|
7
|
+
} from "../lib/fleet-sync.js";
|
|
8
|
+
import { startLocal, stopLocal } from "../lib/processes.js";
|
|
9
|
+
import { startTunnel, stopTunnel } from "../lib/tunnels.js";
|
|
10
|
+
import { readFleet } from "../lib/fleet-sync.js";
|
|
11
|
+
|
|
12
|
+
const NAME_RE = /^[a-z][a-z0-9-]{1,30}$/;
|
|
13
|
+
const VALID_LOCATIONS = new Set(["docker", "ec2", "local"]);
|
|
14
|
+
|
|
15
|
+
// Map of allowed file names to role_config config_type values
|
|
16
|
+
const FILE_TO_CONFIG_TYPE: Record<string, string> = {
|
|
17
|
+
"SOUL.md": "soul",
|
|
18
|
+
"USER.md": "identity",
|
|
19
|
+
"AGENTS.md": "agents",
|
|
20
|
+
"MEMORY.md": "soul", // memory is part of soul for now
|
|
21
|
+
"TOOLS.md": "tools",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const agents = new Hono();
|
|
25
|
+
|
|
26
|
+
// List available roles (from DB role_configs)
|
|
27
|
+
agents.get("/roles", async (c) => {
|
|
28
|
+
const rows = await db
|
|
29
|
+
.selectFrom("role_configs")
|
|
30
|
+
.select("role")
|
|
31
|
+
.distinct()
|
|
32
|
+
.execute();
|
|
33
|
+
return c.json(rows.map((r) => r.role));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
agents.get("/", async (c) => {
|
|
37
|
+
const rows = await db.selectFrom("agents").selectAll().execute();
|
|
38
|
+
return c.json(rows);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
agents.get("/:name", async (c) => {
|
|
42
|
+
const agent = await resolveAgent(c.req.param("name"));
|
|
43
|
+
if (!agent) return c.json({ error: "not found" }, 404);
|
|
44
|
+
return c.json(agent);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Get agent config file from DB
|
|
48
|
+
agents.get("/:name/files/:filename", async (c) => {
|
|
49
|
+
const filename = c.req.param("filename");
|
|
50
|
+
const configType = FILE_TO_CONFIG_TYPE[filename];
|
|
51
|
+
if (!configType) {
|
|
52
|
+
return c.json({ error: "invalid filename" }, 400);
|
|
53
|
+
}
|
|
54
|
+
const agent = await resolveAgent(c.req.param("name"));
|
|
55
|
+
if (!agent) return c.json({ error: "not found" }, 404);
|
|
56
|
+
|
|
57
|
+
const config = await db
|
|
58
|
+
.selectFrom("role_configs")
|
|
59
|
+
.where("role", "=", agent.role)
|
|
60
|
+
.where("config_type", "=", configType)
|
|
61
|
+
.selectAll()
|
|
62
|
+
.executeTakeFirst();
|
|
63
|
+
|
|
64
|
+
if (!config) {
|
|
65
|
+
return c.json({ error: "file not found" }, 404);
|
|
66
|
+
}
|
|
67
|
+
return c.json({ filename, content: config.content });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Get agent's role config by config_type directly
|
|
71
|
+
agents.get("/:name/config/:configType", async (c) => {
|
|
72
|
+
const agent = await resolveAgent(c.req.param("name"));
|
|
73
|
+
if (!agent) return c.json({ error: "not found" }, 404);
|
|
74
|
+
|
|
75
|
+
const config = await db
|
|
76
|
+
.selectFrom("role_configs")
|
|
77
|
+
.where("role", "=", agent.role)
|
|
78
|
+
.where("config_type", "=", c.req.param("configType"))
|
|
79
|
+
.selectAll()
|
|
80
|
+
.executeTakeFirst();
|
|
81
|
+
|
|
82
|
+
if (!config) return c.json({ error: "not found" }, 404);
|
|
83
|
+
return c.json(config);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Create agent
|
|
87
|
+
agents.post("/", async (c) => {
|
|
88
|
+
const body = await c.req.json<{
|
|
89
|
+
name: string;
|
|
90
|
+
role: string;
|
|
91
|
+
location: string;
|
|
92
|
+
slack_bot_token?: string;
|
|
93
|
+
slack_app_token?: string;
|
|
94
|
+
}>();
|
|
95
|
+
|
|
96
|
+
if (!NAME_RE.test(body.name)) {
|
|
97
|
+
return c.json({ error: "invalid name: must match /^[a-z][a-z0-9-]{1,30}$/" }, 400);
|
|
98
|
+
}
|
|
99
|
+
if (!VALID_LOCATIONS.has(body.location)) {
|
|
100
|
+
return c.json({ error: "invalid location: must be docker, ec2, or local" }, 400);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const existing = await db
|
|
104
|
+
.selectFrom("agents")
|
|
105
|
+
.select("id")
|
|
106
|
+
.where("name", "=", body.name)
|
|
107
|
+
.executeTakeFirst();
|
|
108
|
+
if (existing) {
|
|
109
|
+
return c.json({ error: "agent already exists" }, 409);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Write to fleet.json
|
|
113
|
+
await writeAgentToFleet(body.name, {
|
|
114
|
+
role: body.role,
|
|
115
|
+
location: body.location,
|
|
116
|
+
...(body.slack_bot_token ? { slackBotToken: body.slack_bot_token } : {}),
|
|
117
|
+
...(body.slack_app_token ? { slackAppToken: body.slack_app_token } : {}),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Insert into DB
|
|
121
|
+
const agent = await db
|
|
122
|
+
.insertInto("agents")
|
|
123
|
+
.values({
|
|
124
|
+
id: crypto.randomUUID(),
|
|
125
|
+
name: body.name,
|
|
126
|
+
role: body.role,
|
|
127
|
+
location: body.location,
|
|
128
|
+
slack_bot_token: body.slack_bot_token ?? null,
|
|
129
|
+
slack_app_token: body.slack_app_token ?? null,
|
|
130
|
+
status: "idle",
|
|
131
|
+
current_task: null,
|
|
132
|
+
session_key: `agent:${body.name}:main`,
|
|
133
|
+
})
|
|
134
|
+
.returningAll()
|
|
135
|
+
.executeTakeFirstOrThrow();
|
|
136
|
+
|
|
137
|
+
return c.json(agent, 201);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Update agent
|
|
141
|
+
agents.patch("/:name", async (c) => {
|
|
142
|
+
const agent = await resolveAgent(c.req.param("name"));
|
|
143
|
+
if (!agent) return c.json({ error: "not found" }, 404);
|
|
144
|
+
|
|
145
|
+
const body = await c.req.json<{
|
|
146
|
+
status?: string;
|
|
147
|
+
current_task?: string | null;
|
|
148
|
+
role?: string;
|
|
149
|
+
location?: string;
|
|
150
|
+
slack_bot_token?: string | null;
|
|
151
|
+
slack_app_token?: string | null;
|
|
152
|
+
}>();
|
|
153
|
+
|
|
154
|
+
let q = db.updateTable("agents").where("id", "=", agent.id);
|
|
155
|
+
if (body.status !== undefined) q = q.set("status", body.status);
|
|
156
|
+
if (body.current_task !== undefined) q = q.set("current_task", body.current_task);
|
|
157
|
+
if (body.role !== undefined) q = q.set("role", body.role);
|
|
158
|
+
if (body.location !== undefined) {
|
|
159
|
+
if (!VALID_LOCATIONS.has(body.location)) {
|
|
160
|
+
return c.json({ error: "invalid location" }, 400);
|
|
161
|
+
}
|
|
162
|
+
q = q.set("location", body.location);
|
|
163
|
+
}
|
|
164
|
+
if (body.slack_bot_token !== undefined) q = q.set("slack_bot_token", body.slack_bot_token);
|
|
165
|
+
if (body.slack_app_token !== undefined) q = q.set("slack_app_token", body.slack_app_token);
|
|
166
|
+
q = q.set("updated_at", new Date().toISOString());
|
|
167
|
+
|
|
168
|
+
const updated = await q.returningAll().executeTakeFirstOrThrow();
|
|
169
|
+
|
|
170
|
+
// Sync config fields to fleet.json
|
|
171
|
+
if (body.role !== undefined || body.location !== undefined ||
|
|
172
|
+
body.slack_bot_token !== undefined ||
|
|
173
|
+
body.slack_app_token !== undefined) {
|
|
174
|
+
await writeAgentToFleet(agent.name, {
|
|
175
|
+
role: updated.role,
|
|
176
|
+
location: updated.location ?? "local",
|
|
177
|
+
...(updated.slack_bot_token ? { slackBotToken: updated.slack_bot_token } : {}),
|
|
178
|
+
...(updated.slack_app_token ? { slackAppToken: updated.slack_app_token } : {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return c.json(updated);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Delete agent
|
|
186
|
+
agents.delete("/:name", async (c) => {
|
|
187
|
+
const agent = await resolveAgent(c.req.param("name"));
|
|
188
|
+
if (!agent) return c.json({ error: "not found" }, 404);
|
|
189
|
+
|
|
190
|
+
// Stop docker container if running
|
|
191
|
+
if (agent.location === "docker") {
|
|
192
|
+
try {
|
|
193
|
+
const proc = Bun.spawn(
|
|
194
|
+
["docker", "compose", "-f", "docker-compose.agents.yml", "stop", `agent-${agent.name}`],
|
|
195
|
+
{ cwd: process.cwd(), stdout: "inherit", stderr: "inherit" }
|
|
196
|
+
);
|
|
197
|
+
await proc.exited;
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await db.deleteFrom("agents").where("id", "=", agent.id).execute();
|
|
202
|
+
await removeAgentFromFleet(agent.name);
|
|
203
|
+
|
|
204
|
+
return c.json({ ok: true });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Deploy agent
|
|
208
|
+
agents.post("/:name/deploy", async (c) => {
|
|
209
|
+
const agent = await resolveAgent(c.req.param("name"));
|
|
210
|
+
if (!agent) return c.json({ error: "not found" }, 404);
|
|
211
|
+
|
|
212
|
+
if (agent.location === "local") {
|
|
213
|
+
startLocal(agent.name, agent.role);
|
|
214
|
+
await db
|
|
215
|
+
.updateTable("agents")
|
|
216
|
+
.where("id", "=", agent.id)
|
|
217
|
+
.set({ status: "active", updated_at: new Date().toISOString() })
|
|
218
|
+
.execute();
|
|
219
|
+
return c.json({ status: "deployed", method: "local" });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (agent.location === "ec2") {
|
|
223
|
+
const fleet = readFleet();
|
|
224
|
+
const fleetAgent = fleet.agents[agent.name];
|
|
225
|
+
const host = fleetAgent?.host;
|
|
226
|
+
if (!host) {
|
|
227
|
+
return c.json({ error: "No host configured for EC2 agent. Set 'host' in fleet.json or agent config." }, 400);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Open reverse SSH tunnel so agent can reach our API at localhost:3100
|
|
231
|
+
try {
|
|
232
|
+
await startTunnel(agent.name, host);
|
|
233
|
+
} catch (err: any) {
|
|
234
|
+
return c.json({ error: `Failed to open SSH tunnel: ${err.message}` }, 500);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await db
|
|
238
|
+
.updateTable("agents")
|
|
239
|
+
.where("id", "=", agent.id)
|
|
240
|
+
.set({ status: "active", updated_at: new Date().toISOString() })
|
|
241
|
+
.execute();
|
|
242
|
+
return c.json({ status: "deployed", method: "ec2", tunnel: true });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (agent.location === "docker") {
|
|
246
|
+
const proc = Bun.spawn(
|
|
247
|
+
["docker", "compose", "-f", "docker-compose.agents.yml", "up", "-d", `agent-${agent.name}`],
|
|
248
|
+
{ cwd: process.cwd(), stdout: "inherit", stderr: "inherit" }
|
|
249
|
+
);
|
|
250
|
+
const exitCode = await proc.exited;
|
|
251
|
+
if (exitCode !== 0) {
|
|
252
|
+
return c.json({ error: "docker compose up failed" }, 500);
|
|
253
|
+
}
|
|
254
|
+
await db
|
|
255
|
+
.updateTable("agents")
|
|
256
|
+
.where("id", "=", agent.id)
|
|
257
|
+
.set({ status: "active", updated_at: new Date().toISOString() })
|
|
258
|
+
.execute();
|
|
259
|
+
return c.json({ status: "deployed", method: "docker" });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return c.json({ error: "unsupported location" }, 400);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Stop agent
|
|
266
|
+
agents.post("/:name/stop", async (c) => {
|
|
267
|
+
const agent = await resolveAgent(c.req.param("name"));
|
|
268
|
+
if (!agent) return c.json({ error: "not found" }, 404);
|
|
269
|
+
|
|
270
|
+
if (agent.location === "local") {
|
|
271
|
+
const stopped = stopLocal(agent.name);
|
|
272
|
+
if (!stopped) {
|
|
273
|
+
return c.json({ error: "no local process found" }, 400);
|
|
274
|
+
}
|
|
275
|
+
await db
|
|
276
|
+
.updateTable("agents")
|
|
277
|
+
.where("id", "=", agent.id)
|
|
278
|
+
.set({ status: "idle", updated_at: new Date().toISOString() })
|
|
279
|
+
.execute();
|
|
280
|
+
return c.json({ status: "stopped" });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (agent.location === "docker") {
|
|
284
|
+
const proc = Bun.spawn(
|
|
285
|
+
["docker", "compose", "-f", "docker-compose.agents.yml", "stop", `agent-${agent.name}`],
|
|
286
|
+
{ cwd: process.cwd(), stdout: "inherit", stderr: "inherit" }
|
|
287
|
+
);
|
|
288
|
+
const exitCode = await proc.exited;
|
|
289
|
+
if (exitCode !== 0) {
|
|
290
|
+
return c.json({ error: "docker compose stop failed" }, 500);
|
|
291
|
+
}
|
|
292
|
+
await db
|
|
293
|
+
.updateTable("agents")
|
|
294
|
+
.where("id", "=", agent.id)
|
|
295
|
+
.set({ status: "idle", updated_at: new Date().toISOString() })
|
|
296
|
+
.execute();
|
|
297
|
+
return c.json({ status: "stopped" });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (agent.location === "ec2") {
|
|
301
|
+
stopTunnel(agent.name);
|
|
302
|
+
await db
|
|
303
|
+
.updateTable("agents")
|
|
304
|
+
.where("id", "=", agent.id)
|
|
305
|
+
.set({ status: "idle", updated_at: new Date().toISOString() })
|
|
306
|
+
.execute();
|
|
307
|
+
return c.json({ status: "stopped", tunnel: "closed" });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return c.json({ error: "stop not supported for this agent location" }, 400);
|
|
311
|
+
});
|