maestro-agent 0.0.1 → 0.0.3

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 (111) hide show
  1. package/README.md +316 -2
  2. package/bin/maestro.ts +5 -0
  3. package/dist/maestro +0 -0
  4. package/dist/web/apple-touch-icon.png +0 -0
  5. package/dist/web/assets/Connections-BMA04Ycg.js +11 -0
  6. package/dist/web/assets/GanttView-DXjh0gxg.js +49 -0
  7. package/dist/web/assets/Home-Ct3Ho0Qt.js +1 -0
  8. package/dist/web/assets/HooksCrons--0kyVJcR.js +11 -0
  9. package/dist/web/assets/ProjectDetail-B_IqEpFu.js +1 -0
  10. package/dist/web/assets/Roles-D1tIQzto.js +24 -0
  11. package/dist/web/assets/Settings-yts4LUmH.js +11 -0
  12. package/dist/web/assets/Skills-DbuNLjIV.js +12 -0
  13. package/dist/web/assets/Wizard-vJol8-Y4.js +11 -0
  14. package/dist/web/assets/WorkspaceChat-DrsLs4m2.js +56 -0
  15. package/dist/web/assets/WorkspaceDashboard-B9vgrd2Z.js +6 -0
  16. package/dist/web/assets/WorkspaceNew-DoNGYHCG.js +1 -0
  17. package/dist/web/assets/WorkspaceProjects-DDp3mUse.js +6 -0
  18. package/dist/web/assets/WorkspaceSchedules-BTjmCbYG.js +1 -0
  19. package/dist/web/assets/WorkspaceTasks-mPU-bhKR.js +41 -0
  20. package/dist/web/assets/activity-CIA8bIA4.js +6 -0
  21. package/dist/web/assets/addon-fit-BlxrFPDK.js +1 -0
  22. package/dist/web/assets/arrow-right-S7ID7nDp.js +6 -0
  23. package/dist/web/assets/badge-DDTUzWIi.js +1 -0
  24. package/dist/web/assets/circle-check-B3P1qK0Z.js +6 -0
  25. package/dist/web/assets/clock-f9aYZox0.js +6 -0
  26. package/dist/web/assets/index-BRo4Du_s.js +11 -0
  27. package/dist/web/assets/index-C7kx39S9.js +196 -0
  28. package/dist/web/assets/index-D6LSdZea.css +1 -0
  29. package/dist/web/assets/plus-BHnOxbns.js +6 -0
  30. package/dist/web/assets/refresh-cw-BWX04Hg3.js +6 -0
  31. package/dist/web/assets/save-BLbb_9xz.js +6 -0
  32. package/dist/web/assets/sparkles-CDr6Dw1e.js +6 -0
  33. package/dist/web/assets/trash-2-9-ThEdey.js +6 -0
  34. package/dist/web/assets/useEventStream-DXt2Hmei.js +1 -0
  35. package/dist/web/assets/x-DVdKPXXy.js +6 -0
  36. package/dist/web/assets/xterm-DYP7pi_n.css +32 -0
  37. package/dist/web/assets/xterm-DlVFs1Kw.js +9 -0
  38. package/dist/web/favicon-512.png +0 -0
  39. package/dist/web/favicon.png +0 -0
  40. package/dist/web/index.html +15 -0
  41. package/package.json +49 -6
  42. package/src/api/agents.ts +76 -0
  43. package/src/api/audit.ts +19 -0
  44. package/src/api/autopilot.ts +73 -0
  45. package/src/api/chat.ts +801 -0
  46. package/src/api/chief.ts +84 -0
  47. package/src/api/config.ts +39 -0
  48. package/src/api/gantt.ts +72 -0
  49. package/src/api/hooks.ts +54 -0
  50. package/src/api/inbox.ts +125 -0
  51. package/src/api/lark.ts +32 -0
  52. package/src/api/memory.ts +37 -0
  53. package/src/api/ops.ts +89 -0
  54. package/src/api/projects.ts +105 -0
  55. package/src/api/roles.ts +123 -0
  56. package/src/api/runtimes.ts +62 -0
  57. package/src/api/scheduled-tasks.ts +203 -0
  58. package/src/api/sessions.ts +479 -0
  59. package/src/api/skills.ts +386 -0
  60. package/src/api/tasks.ts +457 -0
  61. package/src/api/telegram.ts +94 -0
  62. package/src/api/templates.ts +36 -0
  63. package/src/api/webhooks.ts +20 -0
  64. package/src/api/workspaces.ts +150 -0
  65. package/src/bridges/lark/index.ts +213 -0
  66. package/src/bridges/telegram/index.ts +273 -0
  67. package/src/bridges/telegram/polling.ts +185 -0
  68. package/src/chat/index.ts +86 -0
  69. package/src/chief/index.ts +461 -0
  70. package/src/core/cli.ts +333 -0
  71. package/src/core/db.ts +53 -0
  72. package/src/core/event-bus.ts +33 -0
  73. package/src/core/index.ts +6 -0
  74. package/src/core/migrations.ts +303 -0
  75. package/src/core/router.ts +69 -0
  76. package/src/core/schema.sql +232 -0
  77. package/src/core/server.ts +308 -0
  78. package/src/core/validate.ts +22 -0
  79. package/src/discovery/index.ts +194 -0
  80. package/src/gateway/adapters/telegram.ts +148 -0
  81. package/src/gateway/index.ts +31 -0
  82. package/src/gateway/manager.ts +176 -0
  83. package/src/gateway/types.ts +77 -0
  84. package/src/inbox/index.ts +500 -0
  85. package/src/ops/artifact-sync.ts +65 -0
  86. package/src/ops/autopilot.ts +338 -0
  87. package/src/ops/gc.ts +252 -0
  88. package/src/ops/index.ts +226 -0
  89. package/src/ops/project-serial.ts +52 -0
  90. package/src/ops/role-dispatch.ts +111 -0
  91. package/src/ops/runtime-scheduler.ts +447 -0
  92. package/src/ops/task-blocking.ts +65 -0
  93. package/src/ops/task-deps.ts +37 -0
  94. package/src/ops/task-workspace.ts +60 -0
  95. package/src/roles/index.ts +258 -0
  96. package/src/roles/prompt-assembler.ts +85 -0
  97. package/src/roles/workspace-role.ts +155 -0
  98. package/src/scheduler/index.ts +461 -0
  99. package/src/session/output-parser.ts +75 -0
  100. package/src/session/realtime-parser.ts +40 -0
  101. package/src/skills/builtin.ts +155 -0
  102. package/src/skills/skill-extractor.ts +452 -0
  103. package/src/skills/skill-md.ts +282 -0
  104. package/src/transport/http-api.ts +75 -0
  105. package/src/transport/index.ts +4 -0
  106. package/src/transport/local-pty.ts +119 -0
  107. package/src/transport/ssh.ts +176 -0
  108. package/src/transport/types.ts +20 -0
  109. package/src/workflows/index.ts +231 -0
  110. package/index.js +0 -1
  111. package/maestro-agent-0.0.1.tgz +0 -0
@@ -0,0 +1,308 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { registerAgentRoutes } from "../api/agents";
5
+ import { registerAuditRoutes } from "../api/audit";
6
+ import { registerAutopilotRoutes, initAutopilot } from "../api/autopilot";
7
+ import { registerChatRoutes } from "../api/chat";
8
+ import { registerChiefRoutes } from "../api/chief";
9
+ import { getConfig, registerConfigRoutes } from "../api/config";
10
+ import { registerGanttRoutes } from "../api/gantt";
11
+ import { registerHookRoutes } from "../api/hooks";
12
+ import { registerInboxRoutes } from "../api/inbox";
13
+ import { registerLarkRoutes } from "../api/lark";
14
+ import { registerMemoryRoutes } from "../api/memory";
15
+ import { registerOpsRoutes } from "../api/ops";
16
+ import { registerProjectRoutes } from "../api/projects";
17
+ import { registerRoleRoutes } from "../api/roles";
18
+ import { registerRuntimeRoutes } from "../api/runtimes";
19
+ import { registerScheduledTaskRoutes } from "../api/scheduled-tasks";
20
+ import { registerSessionRoutes } from "../api/sessions";
21
+ import { registerSkillRoutes } from "../api/skills";
22
+ import { registerTaskRoutes } from "../api/tasks";
23
+ import { registerTelegramRoutes } from "../api/telegram";
24
+ import { registerTemplateRoutes } from "../api/templates";
25
+ import { registerWebhookRoutes } from "../api/webhooks";
26
+ import { registerWorkspaceRoutes } from "../api/workspaces";
27
+ import { createClaudePlanner, runChiefHeartbeat } from "../chief";
28
+ import { registerDiscoveredRuntimes } from "../discovery";
29
+ import { startGateway } from "../gateway";
30
+ import { recoverInterruptedSessions } from "../ops";
31
+ import { createSessionQueue, startRuntimeScheduler, type SessionQueue } from "../ops/runtime-scheduler";
32
+ import { reloadRolesIfChanged } from "../roles";
33
+ import { runDueScheduledTasks } from "../scheduler";
34
+ import { ensureBuiltinSkills } from "../skills/builtin";
35
+ import { ensureDefaultWorkflow } from "../workflows";
36
+ import { getDb, initSchema } from "./db";
37
+ import { EventBus } from "./event-bus";
38
+ import { Router, json } from "./router";
39
+
40
+ interface EventClient {
41
+ ws: any;
42
+ filters: string[]; // glob patterns, empty = all
43
+ }
44
+
45
+ export interface HubContext {
46
+ db: Database;
47
+ bus: EventBus;
48
+ hubDir: string;
49
+ workspaceRoot: string;
50
+ port: number;
51
+ sessions: Map<string, any>; // sessionId → PtyProcess
52
+ wsClients: Map<string, Set<any>>; // sessionId → Set<ws>
53
+ eventClients: Set<EventClient>; // /space/events subscribers
54
+ sessionQueue: SessionQueue; // runtimeId → waiting queue
55
+ }
56
+
57
+ export async function startServer(hubDir: string, port = 7423) {
58
+ const db = getDb(hubDir);
59
+ initSchema(db, hubDir);
60
+ ensureBuiltinSkills(db, hubDir, { sync: false });
61
+ ensureDefaultWorkflow(db);
62
+ recoverInterruptedSessions(db);
63
+
64
+ // Auto-discover and register local agent CLIs on startup
65
+ registerDiscoveredRuntimes(db).catch(() => {});
66
+
67
+ const bus = new EventBus(db);
68
+ const ctx: HubContext = {
69
+ db,
70
+ bus,
71
+ hubDir,
72
+ workspaceRoot: process.cwd(),
73
+ port,
74
+ sessions: new Map(),
75
+ wsClients: new Map(),
76
+ eventClients: new Set(),
77
+ sessionQueue: createSessionQueue(),
78
+ };
79
+
80
+ // Wire EventBus → WebSocket broadcast
81
+ bus.setBroadcast((type, payload, ts) => {
82
+ const msg = JSON.stringify({ type, payload, ts });
83
+ for (const client of ctx.eventClients) {
84
+ if (client.filters.length === 0 || client.filters.some((f) => matchGlob(f, type))) {
85
+ try { client.ws.send(msg); } catch {}
86
+ }
87
+ }
88
+
89
+ // Forward to configured IM bridges
90
+ if (type.startsWith("inbox.")) {
91
+ const larkUrl = (db.query("SELECT value FROM config WHERE key = 'lark_webhook_url'").get() as any)?.value;
92
+ if (larkUrl) {
93
+ import("../bridges/lark").then(({ sendLarkNotification }) => {
94
+ sendLarkNotification({ webhook_url: larkUrl, kind: type, body: JSON.stringify(payload) }).catch(() => {});
95
+ });
96
+ }
97
+ }
98
+ });
99
+
100
+ const router = new Router();
101
+ const rolesDir = join(hubDir, "roles");
102
+ const roleReloadState = { signature: "" };
103
+ reloadRolesIfChanged(db, rolesDir, roleReloadState);
104
+ const roleReloadTimer = setInterval(() => {
105
+ reloadRolesIfChanged(db, rolesDir, roleReloadState);
106
+ }, 5000);
107
+ roleReloadTimer.unref?.();
108
+
109
+ // 注册 API 路由
110
+ registerWorkspaceRoutes(router, ctx);
111
+ registerProjectRoutes(router, ctx);
112
+ registerTaskRoutes(router, ctx);
113
+ registerAgentRoutes(router, ctx);
114
+ registerRuntimeRoutes(router, ctx);
115
+ registerSessionRoutes(router, ctx);
116
+ registerMemoryRoutes(router, ctx);
117
+ registerChatRoutes(router, ctx);
118
+ registerInboxRoutes(router, ctx);
119
+ registerHookRoutes(router, ctx);
120
+ registerAuditRoutes(router, ctx);
121
+ registerRoleRoutes(router, ctx);
122
+ registerChiefRoutes(router, ctx);
123
+ registerLarkRoutes(router, ctx);
124
+ registerTelegramRoutes(router, ctx);
125
+ registerOpsRoutes(router, ctx);
126
+ registerWebhookRoutes(router, ctx);
127
+ registerConfigRoutes(router, ctx);
128
+ registerGanttRoutes(router, ctx);
129
+ registerTemplateRoutes(router, ctx);
130
+ registerSkillRoutes(router, ctx);
131
+ registerScheduledTaskRoutes(router, ctx);
132
+ registerAutopilotRoutes(router, ctx);
133
+
134
+ // 静态文件目录
135
+ const webDistDir = join(import.meta.dir, "../../dist/web");
136
+
137
+ const listenPort = port === 0 ? await getAvailablePort() : port;
138
+ const server = Bun.serve({
139
+ port: listenPort,
140
+ async fetch(req, server) {
141
+ const url = new URL(req.url);
142
+
143
+ // WebSocket upgrade
144
+ if (url.pathname === "/space/events" || url.pathname.startsWith("/space/sessions/")) {
145
+ if (url.pathname === "/space/events") {
146
+ const filter = url.searchParams.get("filter") || "";
147
+ const filters = filter ? filter.split(",").map((f) => f.trim()) : [];
148
+ const upgraded = server.upgrade(req, { data: { kind: "events", filters } as any });
149
+ if (upgraded) return undefined as any;
150
+ return json({ error: "WebSocket upgrade failed" }, 400);
151
+ }
152
+ const sessionId = url.pathname.replace("/space/sessions/", "");
153
+ const upgraded = server.upgrade(req, { data: { kind: "session", sessionId } as any });
154
+ if (upgraded) return undefined as any;
155
+ return json({ error: "WebSocket upgrade failed" }, 400);
156
+ }
157
+
158
+ // REST API
159
+ if (url.pathname.startsWith("/api/")) {
160
+ return router.handle(req);
161
+ }
162
+
163
+ // 静态文件
164
+ let filePath = join(webDistDir, url.pathname === "/" ? "index.html" : url.pathname);
165
+ if (!existsSync(filePath)) {
166
+ filePath = join(webDistDir, "index.html"); // SPA fallback
167
+ }
168
+ if (existsSync(filePath)) {
169
+ return new Response(Bun.file(filePath));
170
+ }
171
+
172
+ return json({ error: "Not found" }, 404);
173
+ },
174
+ websocket: {
175
+ open(ws) {
176
+ const data = ws.data as any;
177
+ if (data.kind === "events") {
178
+ const client: EventClient = { ws, filters: data.filters || [] };
179
+ (ws as any)._eventClient = client;
180
+ ctx.eventClients.add(client);
181
+ return;
182
+ }
183
+ const { sessionId } = data;
184
+ if (!ctx.wsClients.has(sessionId)) {
185
+ ctx.wsClients.set(sessionId, new Set());
186
+ }
187
+ ctx.wsClients.get(sessionId)!.add(ws);
188
+ replaySession(ws, ctx, sessionId);
189
+ },
190
+ message(ws, msg) {
191
+ const data = ws.data as any;
192
+ if (data.kind === "events") return; // events ws is read-only for clients
193
+ const { sessionId } = data;
194
+ try {
195
+ const parsed = JSON.parse(msg as string);
196
+ if (parsed.type === "stdin") {
197
+ const proc = ctx.sessions.get(sessionId);
198
+ if (proc?.stdin) {
199
+ proc.stdin.write(parsed.data);
200
+ }
201
+ }
202
+ } catch {}
203
+ },
204
+ close(ws) {
205
+ const data = ws.data as any;
206
+ if (data.kind === "events") {
207
+ const client = (ws as any)._eventClient;
208
+ if (client) ctx.eventClients.delete(client);
209
+ return;
210
+ }
211
+ const { sessionId } = data;
212
+ ctx.wsClients.get(sessionId)?.delete(ws);
213
+ },
214
+ },
215
+ });
216
+
217
+ console.log(`🎼 MAESTRO Hub running at http://localhost:${server.port}`);
218
+
219
+ // Chief heartbeat auto-start (configurable, default 15 min, PRD §8.2)
220
+ const chiefIntervalMin = Number(getConfig(ctx, "chief_interval_min", "15"));
221
+ const chiefIntervalMs = Math.max(5, Math.min(60, chiefIntervalMin)) * 60 * 1000;
222
+
223
+ function buildChiefOpts() {
224
+ const apiKey = getConfig(ctx, "chief_api_key", "") || process.env.ANTHROPIC_API_KEY || "";
225
+ if (!apiKey) return {};
226
+ const baseURL = getConfig(ctx, "chief_base_url", "") || process.env.MAESTRO_CHIEF_BASE_URL || "";
227
+ const model = getConfig(ctx, "chief_model", "") || undefined;
228
+ return { planner: createClaudePlanner({ apiKey, baseURL: baseURL || undefined, model }) };
229
+ }
230
+
231
+ const chiefTimer = setInterval(async () => {
232
+ try { await runChiefHeartbeat(db, buildChiefOpts()); } catch (err) {
233
+ console.error("[chief] heartbeat error:", err);
234
+ }
235
+ }, chiefIntervalMs);
236
+ chiefTimer.unref?.();
237
+
238
+ // First heartbeat 30s after startup
239
+ const chiefInitialTimer = setTimeout(async () => {
240
+ try { await runChiefHeartbeat(db, buildChiefOpts()); } catch (err) {
241
+ console.error("[chief] initial heartbeat error:", err);
242
+ }
243
+ }, 30_000);
244
+ (chiefInitialTimer as any).unref?.();
245
+
246
+ const scheduledTaskTimer = setInterval(async () => {
247
+ try { await runDueScheduledTasks(ctx); } catch (err) {
248
+ console.error("[scheduler] scheduled task error:", err);
249
+ }
250
+ }, 30_000);
251
+ scheduledTaskTimer.unref?.();
252
+
253
+ const scheduledTaskInitialTimer = setTimeout(async () => {
254
+ try { await runDueScheduledTasks(ctx); } catch (err) {
255
+ console.error("[scheduler] initial scheduled task error:", err);
256
+ }
257
+ }, 5_000);
258
+ (scheduledTaskInitialTimer as any).unref?.();
259
+
260
+ const runtimeScheduler = startRuntimeScheduler(ctx);
261
+
262
+ // Autopilot: subsumes cron + scheduled task + auto-dispatch
263
+ const autopilot = initAutopilot(ctx);
264
+
265
+ // Telegram long-polling gateway (no webhook needed)
266
+ const gateway = await startGateway(ctx);
267
+
268
+ const originalStop = server.stop.bind(server);
269
+ server.stop = ((closeActiveConnections?: boolean) => {
270
+ clearInterval(roleReloadTimer);
271
+ clearInterval(chiefTimer);
272
+ clearTimeout(chiefInitialTimer);
273
+ clearInterval(scheduledTaskTimer);
274
+ clearTimeout(scheduledTaskInitialTimer);
275
+ runtimeScheduler.stop();
276
+ autopilot.stop();
277
+ gateway?.disconnectAll();
278
+ return originalStop(closeActiveConnections);
279
+ }) as typeof server.stop;
280
+ return server;
281
+ }
282
+
283
+ function replaySession(ws: any, ctx: HubContext, sessionId: string) {
284
+ const session = ctx.db.query("SELECT * FROM session WHERE id = ?").get(sessionId) as any;
285
+ if (!session) {
286
+ ws.send(JSON.stringify({ type: "error", error: "Session not found" }));
287
+ return;
288
+ }
289
+
290
+ if (session.transcript_path && existsSync(session.transcript_path)) {
291
+ const data = readFileSync(session.transcript_path, "utf-8");
292
+ if (data) ws.send(JSON.stringify({ type: "stdout", data }));
293
+ }
294
+
295
+ if (session.status === "ended") {
296
+ ws.send(JSON.stringify({ type: "exit", code: null }));
297
+ }
298
+ }
299
+
300
+ function getAvailablePort(): Promise<number> {
301
+ return Promise.resolve(18000 + Math.floor(Math.random() * 20000));
302
+ }
303
+
304
+ /** Simple glob match: supports "task.*" → matches "task.done", "task.created" etc. */
305
+ function matchGlob(pattern: string, value: string): boolean {
306
+ const regex = new RegExp("^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
307
+ return regex.test(value);
308
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 简单的请求体校验工具
3
+ */
4
+ export class ValidationError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = "ValidationError";
8
+ }
9
+ }
10
+
11
+ export function required(obj: Record<string, any>, fields: string[]): void {
12
+ const missing = fields.filter(f => obj[f] === undefined || obj[f] === null || obj[f] === "");
13
+ if (missing.length > 0) {
14
+ throw new ValidationError(`Missing required fields: ${missing.join(", ")}`);
15
+ }
16
+ }
17
+
18
+ export function oneOf(value: string, allowed: string[], fieldName: string): void {
19
+ if (!allowed.includes(value)) {
20
+ throw new ValidationError(`${fieldName} must be one of: ${allowed.join(", ")}`);
21
+ }
22
+ }
@@ -0,0 +1,194 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { accessSync, constants, existsSync, statSync } from "fs";
3
+ import { join } from "path";
4
+ import { generateId, now } from "../core/db";
5
+
6
+ export interface DiscoveredRuntime {
7
+ type: string;
8
+ cmd: string;
9
+ version: string | null;
10
+ model: string | null;
11
+ status: "ready" | "not_installed";
12
+ }
13
+
14
+ export interface RegisteredRuntime {
15
+ id: string;
16
+ type: string;
17
+ cmd: string;
18
+ version: string | null;
19
+ model: string | null;
20
+ }
21
+
22
+ export interface RuntimeRegistrationResult {
23
+ discovered: DiscoveredRuntime[];
24
+ added: RegisteredRuntime[];
25
+ skipped: DiscoveredRuntime[];
26
+ }
27
+
28
+ interface CandidateSpec {
29
+ type: string;
30
+ commands: string[];
31
+ versionArgs: string[];
32
+ pathEnv: string; // e.g. MAESTRO_CLAUDE_PATH
33
+ modelEnv: string; // e.g. MAESTRO_CLAUDE_MODEL
34
+ }
35
+
36
+ const CANDIDATES: CandidateSpec[] = [
37
+ { type: "claude-code", commands: ["claude", "claude-code"], versionArgs: ["--version"], pathEnv: "MAESTRO_CLAUDE_PATH", modelEnv: "MAESTRO_CLAUDE_MODEL" },
38
+ { type: "codex", commands: ["codex"], versionArgs: ["--version"], pathEnv: "MAESTRO_CODEX_PATH", modelEnv: "MAESTRO_CODEX_MODEL" },
39
+ { type: "gemini", commands: ["gemini"], versionArgs: ["--version"], pathEnv: "MAESTRO_GEMINI_PATH", modelEnv: "MAESTRO_GEMINI_MODEL" },
40
+ { type: "opencode", commands: ["opencode"], versionArgs: ["--version"], pathEnv: "MAESTRO_OPENCODE_PATH", modelEnv: "MAESTRO_OPENCODE_MODEL" },
41
+ { type: "openclaw", commands: ["openclaw"], versionArgs: ["--version"], pathEnv: "MAESTRO_OPENCLAW_PATH", modelEnv: "MAESTRO_OPENCLAW_MODEL" },
42
+ { type: "hermes", commands: ["hermes"], versionArgs: ["--version"], pathEnv: "MAESTRO_HERMES_PATH", modelEnv: "MAESTRO_HERMES_MODEL" },
43
+ { type: "blade", commands: ["blade"], versionArgs: ["--version"], pathEnv: "MAESTRO_BLADE_PATH", modelEnv: "MAESTRO_BLADE_MODEL" },
44
+ { type: "cursor", commands: ["cursor-agent"], versionArgs: ["--version"], pathEnv: "MAESTRO_CURSOR_PATH", modelEnv: "MAESTRO_CURSOR_MODEL" },
45
+ { type: "kimi", commands: ["kimi"], versionArgs: ["--version"], pathEnv: "MAESTRO_KIMI_PATH", modelEnv: "MAESTRO_KIMI_MODEL" },
46
+ { type: "kiro", commands: ["kiro-cli"], versionArgs: ["--version"], pathEnv: "MAESTRO_KIRO_PATH", modelEnv: "MAESTRO_KIRO_MODEL" },
47
+ { type: "aider", commands: ["aider"], versionArgs: ["--version"], pathEnv: "MAESTRO_AIDER_PATH", modelEnv: "MAESTRO_AIDER_MODEL" },
48
+ ];
49
+
50
+ const TYPE_ALIASES: Record<string, string[]> = {
51
+ "claude-code": ["claude", "claude-code"],
52
+ };
53
+
54
+ function getFullPath(): string {
55
+ const base = process.env.PATH || "";
56
+ const home = process.env.HOME || "";
57
+ const extras = [
58
+ `${home}/.nvm/versions/node/current/bin`,
59
+ `${home}/.volta/bin`,
60
+ `${home}/.bun/bin`,
61
+ "/opt/homebrew/bin",
62
+ "/usr/local/bin",
63
+ ];
64
+ return [...new Set([...base.split(":"), ...extras])].join(":");
65
+ }
66
+
67
+ async function which(cmd: string): Promise<string | null> {
68
+ let bunPath: string | null = null;
69
+ try {
70
+ bunPath = Bun.which(cmd);
71
+ } catch {
72
+ bunPath = null;
73
+ }
74
+
75
+ const path = findInPath(cmd, getFullPath());
76
+ return path ?? bunPath;
77
+ }
78
+
79
+ function findInPath(cmd: string, pathValue: string): string | null {
80
+ for (const dir of pathValue.split(":")) {
81
+ if (!dir) continue;
82
+ const candidate = join(dir, cmd);
83
+ try {
84
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) continue;
85
+ accessSync(candidate, constants.X_OK);
86
+ return candidate;
87
+ } catch {
88
+ continue;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ async function getVersion(cmd: string, args: string[]): Promise<string | null> {
95
+ try {
96
+ const env = { ...process.env, PATH: getFullPath() };
97
+ const proc = Bun.spawn([cmd, ...args], {
98
+ stdout: "pipe",
99
+ stderr: "pipe",
100
+ env,
101
+ signal: AbortSignal.timeout(2000),
102
+ });
103
+ const [code, stdout, stderr] = await Promise.all([
104
+ proc.exited,
105
+ new Response(proc.stdout).text(),
106
+ new Response(proc.stderr).text(),
107
+ ]);
108
+ const text = `${stdout}\n${stderr}`.trim();
109
+ if (code === 0 && text) return text.split("\n")[0].trim();
110
+ } catch {
111
+ // Version probing is best-effort; PATH discovery is the source of truth.
112
+ }
113
+ return null;
114
+ }
115
+
116
+ export async function discoverRuntimes(): Promise<DiscoveredRuntime[]> {
117
+ const results: DiscoveredRuntime[] = [];
118
+
119
+ for (const c of CANDIDATES) {
120
+ const model = process.env[c.modelEnv]?.trim() || null;
121
+
122
+ // Priority 1: explicit path from env var
123
+ const envPath = process.env[c.pathEnv]?.trim();
124
+ if (envPath) {
125
+ const version = await getVersion(envPath, c.versionArgs);
126
+ results.push({ type: c.type, cmd: envPath, version, model, status: "ready" });
127
+ continue;
128
+ }
129
+
130
+ // Priority 2: search PATH for known command names
131
+ for (const command of c.commands) {
132
+ const path = await which(command);
133
+ if (!path) continue;
134
+
135
+ const version = await getVersion(path, c.versionArgs);
136
+ results.push({ type: c.type, cmd: path, version, model, status: "ready" });
137
+ break;
138
+ }
139
+ }
140
+
141
+ return results;
142
+ }
143
+
144
+ export async function registerDiscoveredRuntimes(
145
+ db: Database,
146
+ discovered?: DiscoveredRuntime[]
147
+ ): Promise<RuntimeRegistrationResult> {
148
+ discovered = discovered ?? (await discoverRuntimes());
149
+ const rows = db.query("SELECT type, cmd FROM agent_runtime").all() as { type: string; cmd: string | null }[];
150
+ const existingTypes = new Set(rows.map((row) => row.type));
151
+ const existingCmds = new Set(rows.map((row) => row.cmd).filter(Boolean));
152
+ const added: RegisteredRuntime[] = [];
153
+ const skipped: DiscoveredRuntime[] = [];
154
+ const ts = now();
155
+
156
+ for (const runtime of discovered) {
157
+ const typeAliases = TYPE_ALIASES[runtime.type] ?? [runtime.type];
158
+ if (typeAliases.some((type) => existingTypes.has(type)) || existingCmds.has(runtime.cmd)) {
159
+ skipped.push(runtime);
160
+ continue;
161
+ }
162
+
163
+ const id = generateId("runtime");
164
+ const capabilities = runtime.model ? JSON.stringify([`model:${runtime.model}`]) : "[]";
165
+ db.run(
166
+ "INSERT INTO agent_runtime (id, type, transport, cmd, version, capacity, status, capabilities_json, created_at) VALUES (?, ?, 'local-pty', ?, ?, -1, 'online', ?, ?)",
167
+ [id, runtime.type, runtime.cmd, runtime.version, capabilities, ts]
168
+ );
169
+ existingTypes.add(runtime.type);
170
+ existingCmds.add(runtime.cmd);
171
+ added.push({
172
+ id,
173
+ type: runtime.type,
174
+ cmd: runtime.cmd,
175
+ version: runtime.version,
176
+ model: runtime.model,
177
+ });
178
+ }
179
+
180
+ return {
181
+ discovered,
182
+ added,
183
+ skipped,
184
+ };
185
+ }
186
+
187
+ export function formatRuntimeList(runtimes: Pick<DiscoveredRuntime, "type" | "cmd" | "version" | "model">[]): string[] {
188
+ return runtimes.map((runtime) => {
189
+ let label = `${runtime.type} -> ${runtime.cmd}`;
190
+ if (runtime.version) label += ` (${runtime.version})`;
191
+ if (runtime.model) label += ` [model: ${runtime.model}]`;
192
+ return label;
193
+ });
194
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Telegram Platform Adapter
3
+ *
4
+ * 实现 PlatformAdapter 接口,使用 Long Polling 模式。
5
+ * 无需 webhook、无需公网地址,本地 NAT/防火墙后也能正常工作。
6
+ */
7
+
8
+ import type { InboundMessage, MessageHandler, PlatformAdapter, SendOptions, SendResult } from "../../gateway/types";
9
+
10
+ const TG_API = "https://api.telegram.org";
11
+
12
+ interface TelegramAdapterOptions {
13
+ botToken: string;
14
+ pollingTimeout?: number; // long-poll timeout in seconds, default 30
15
+ onMessage: MessageHandler;
16
+ }
17
+
18
+ export class TelegramAdapter implements PlatformAdapter {
19
+ readonly platform = "telegram";
20
+ readonly textChunkLimit = 4096;
21
+
22
+ private botToken: string;
23
+ private offset = 0;
24
+ private running = false;
25
+ private pollingTimeout: number;
26
+ private abortController: AbortController | null = null;
27
+ private onMessage: MessageHandler;
28
+
29
+ constructor(options: TelegramAdapterOptions) {
30
+ this.botToken = options.botToken;
31
+ this.pollingTimeout = options.pollingTimeout ?? 30;
32
+ this.onMessage = options.onMessage;
33
+ }
34
+
35
+ async connect(): Promise<boolean> {
36
+ if (this.running) return true;
37
+
38
+ // 先删除 webhook(如果之前设置过),切换到 polling 模式
39
+ await this.apiCall("deleteWebhook", {}).catch(() => {});
40
+
41
+ this.running = true;
42
+ this.poll(); // 非阻塞启动
43
+ console.log("[telegram] 💡 Tip: Bot must be Group Admin or have Privacy Mode disabled to receive all messages");
44
+ return true;
45
+ }
46
+
47
+ async disconnect(): Promise<void> {
48
+ this.running = false;
49
+ this.abortController?.abort();
50
+ }
51
+
52
+ async send(chatId: string, text: string, options?: SendOptions): Promise<SendResult> {
53
+ const parseMode = options?.parseMode === "html" ? "HTML"
54
+ : options?.parseMode === "markdown" ? "Markdown"
55
+ : undefined;
56
+
57
+ const payload: any = {
58
+ chat_id: chatId,
59
+ text,
60
+ disable_web_page_preview: true,
61
+ };
62
+ if (parseMode) payload.parse_mode = parseMode;
63
+ if (options?.replyToMessageId) payload.reply_to_message_id = options.replyToMessageId;
64
+ if (options?.threadId) payload.message_thread_id = options.threadId;
65
+
66
+ const result = await this.apiCall("sendMessage", payload);
67
+ if (result?.ok) {
68
+ return { ok: true, messageId: String(result.result?.message_id || "") };
69
+ }
70
+ return { ok: false, error: result?.description || "Unknown error" };
71
+ }
72
+
73
+ async sendTyping(chatId: string): Promise<void> {
74
+ await this.apiCall("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {});
75
+ }
76
+
77
+ // ─── Private ──────────────────────────────────────────────────────────────────
78
+
79
+ private async poll() {
80
+ if (!this.running) return;
81
+ try {
82
+ const updates = await this.getUpdates();
83
+ for (const update of updates) {
84
+ this.offset = update.update_id + 1;
85
+ await this.processUpdate(update).catch((err) => {
86
+ console.error("[telegram] Error processing update:", err.message);
87
+ });
88
+ }
89
+ } catch (err: any) {
90
+ if (!this.running) return;
91
+ console.error("[telegram] Poll error:", err.message);
92
+ await sleep(3000);
93
+ }
94
+ if (this.running) {
95
+ setTimeout(() => this.poll(), 0);
96
+ }
97
+ }
98
+
99
+ private async getUpdates(): Promise<any[]> {
100
+ this.abortController = new AbortController();
101
+ const url = `${TG_API}/bot${this.botToken}/getUpdates?offset=${this.offset}&timeout=${this.pollingTimeout}&allowed_updates=["message","edited_message"]`;
102
+ const res = await fetch(url, { signal: this.abortController.signal });
103
+ if (!res.ok) {
104
+ const text = await res.text().catch(() => "");
105
+ throw new Error(`Telegram API ${res.status}: ${text}`);
106
+ }
107
+ const data = await res.json() as any;
108
+ if (!data.ok) throw new Error(`Telegram error: ${data.description}`);
109
+ return data.result || [];
110
+ }
111
+
112
+ private async processUpdate(update: any) {
113
+ const msg = update.message || update.edited_message;
114
+ if (!msg?.text) return;
115
+
116
+ const chatId = msg.chat?.id;
117
+ if (!chatId) return;
118
+
119
+ const inbound: InboundMessage = {
120
+ platform: "telegram",
121
+ chatId: String(chatId),
122
+ text: msg.text,
123
+ senderId: msg.from?.id ? String(msg.from.id) : undefined,
124
+ senderName: msg.from?.first_name || msg.from?.username,
125
+ threadId: msg.message_thread_id ? String(msg.message_thread_id) : undefined,
126
+ raw: update,
127
+ };
128
+
129
+ await this.onMessage(inbound);
130
+ }
131
+
132
+ private async apiCall(method: string, payload: any): Promise<any> {
133
+ try {
134
+ const res = await fetch(`${TG_API}/bot${this.botToken}/${method}`, {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify(payload),
138
+ });
139
+ return await res.json();
140
+ } catch (err: any) {
141
+ return { ok: false, description: err.message };
142
+ }
143
+ }
144
+ }
145
+
146
+ function sleep(ms: number) {
147
+ return new Promise((resolve) => setTimeout(resolve, ms));
148
+ }