@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.
Files changed (85) hide show
  1. package/README.md +272 -0
  2. package/bin/agency.js +2 -0
  3. package/dashboard/out/404.html +1 -0
  4. package/dashboard/out/_next/static/chunks/255-67e8754147461423.js +1 -0
  5. package/dashboard/out/_next/static/chunks/4bd1b696-c023c6e3521b1417.js +1 -0
  6. package/dashboard/out/_next/static/chunks/app/_not-found/page-ad40673d821037f6.js +1 -0
  7. package/dashboard/out/_next/static/chunks/app/layout-056f12675e691d12.js +1 -0
  8. package/dashboard/out/_next/static/chunks/app/page-80f01fdbb09b43c8.js +1 -0
  9. package/dashboard/out/_next/static/chunks/framework-de98b93a850cfc71.js +1 -0
  10. package/dashboard/out/_next/static/chunks/main-1a0dcce460eb61ce.js +1 -0
  11. package/dashboard/out/_next/static/chunks/main-app-1d848b791b823fa6.js +1 -0
  12. package/dashboard/out/_next/static/chunks/pages/_app-7d307437aca18ad4.js +1 -0
  13. package/dashboard/out/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js +1 -0
  14. package/dashboard/out/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  15. package/dashboard/out/_next/static/chunks/webpack-4e6bf084ac60582b.js +1 -0
  16. package/dashboard/out/_next/static/css/27d1ea794f04e96a.css +1 -0
  17. package/dashboard/out/_next/static/pU1nwWH_dNUOCI8y4nl3C/_buildManifest.js +1 -0
  18. package/dashboard/out/_next/static/pU1nwWH_dNUOCI8y4nl3C/_ssgManifest.js +1 -0
  19. package/dashboard/out/index.html +1 -0
  20. package/dashboard/out/index.txt +19 -0
  21. package/docs/images/agency_cli_ps.png +0 -0
  22. package/docs/images/agency_ui_ai_prodivder_settings.png +0 -0
  23. package/docs/images/agency_ui_aws_settings.png +0 -0
  24. package/docs/images/agency_ui_identity_settings.png +0 -0
  25. package/docs/images/agency_ui_mission_control.png +0 -0
  26. package/docs/images/agent_ui_agent_config.png +0 -0
  27. package/package.json +31 -0
  28. package/src/api/db/client.ts +16 -0
  29. package/src/api/db/migrate.ts +37 -0
  30. package/src/api/db/migrations/001_initial.ts +193 -0
  31. package/src/api/db/migrations/002_configs.ts +76 -0
  32. package/src/api/db/migrations/003_settings_columns.ts +13 -0
  33. package/src/api/db/seed.ts +142 -0
  34. package/src/api/db/types.ts +126 -0
  35. package/src/api/index.ts +73 -0
  36. package/src/api/lib/activity.ts +13 -0
  37. package/src/api/lib/fleet-sync.ts +156 -0
  38. package/src/api/lib/mentions.ts +59 -0
  39. package/src/api/lib/processes.ts +45 -0
  40. package/src/api/lib/resolve-agent.ts +5 -0
  41. package/src/api/lib/tunnels.ts +99 -0
  42. package/src/api/routes/activities.ts +27 -0
  43. package/src/api/routes/agents.ts +311 -0
  44. package/src/api/routes/documents.ts +41 -0
  45. package/src/api/routes/knowledge.ts +60 -0
  46. package/src/api/routes/messages.ts +54 -0
  47. package/src/api/routes/notifications.ts +40 -0
  48. package/src/api/routes/oauth.ts +171 -0
  49. package/src/api/routes/role-configs.ts +71 -0
  50. package/src/api/routes/settings.ts +94 -0
  51. package/src/api/routes/skills.ts +76 -0
  52. package/src/api/routes/tasks.ts +154 -0
  53. package/src/cli/commands/config.ts +42 -0
  54. package/src/cli/commands/daemon.ts +173 -0
  55. package/src/cli/commands/doc.ts +47 -0
  56. package/src/cli/commands/init.ts +105 -0
  57. package/src/cli/commands/learn.ts +51 -0
  58. package/src/cli/commands/logs.ts +31 -0
  59. package/src/cli/commands/msg.ts +18 -0
  60. package/src/cli/commands/ps.ts +19 -0
  61. package/src/cli/commands/recall.ts +18 -0
  62. package/src/cli/commands/skills.ts +66 -0
  63. package/src/cli/commands/ssh.ts +68 -0
  64. package/src/cli/commands/start.ts +14 -0
  65. package/src/cli/commands/status.ts +33 -0
  66. package/src/cli/commands/stop.ts +11 -0
  67. package/src/cli/commands/tasks.ts +150 -0
  68. package/src/cli/index.ts +70 -0
  69. package/src/cli/lib/api.ts +16 -0
  70. package/src/cli/lib/config.ts +5 -0
  71. package/src/cli/lib/find-root.ts +32 -0
  72. package/src/cli/lib/prompt.ts +20 -0
  73. package/src/daemon.ts +83 -0
  74. package/src/templates/implementer/agents-config.md +44 -0
  75. package/src/templates/implementer/agents.md +32 -0
  76. package/src/templates/implementer/heartbeat.md +47 -0
  77. package/src/templates/implementer/tools.md +33 -0
  78. package/src/templates/orchestrator/agents-config.md +44 -0
  79. package/src/templates/orchestrator/agents.md +27 -0
  80. package/src/templates/orchestrator/heartbeat.md +40 -0
  81. package/src/templates/orchestrator/tools.md +40 -0
  82. package/src/templates/shared/environment.md +20 -0
  83. package/src/templates/shared/memory.md +20 -0
  84. package/src/templates/shared/soul.md +26 -0
  85. 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,5 @@
1
+ import { db } from "../db/client.js";
2
+
3
+ export async function resolveAgent(name: string) {
4
+ return db.selectFrom("agents").where("name", "=", name).selectAll().executeTakeFirst();
5
+ }
@@ -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
+ });