maestro-agent 0.0.1 → 0.0.2

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 (94) 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/assets/Connections-DV2Kql1Z.js +1 -0
  5. package/dist/web/assets/GanttView-CCT_rFpY.js +39 -0
  6. package/dist/web/assets/Home-BFbUIh2z.js +1 -0
  7. package/dist/web/assets/HooksCrons-ASM5-jDm.js +1 -0
  8. package/dist/web/assets/ProjectDetail-KZZi6IAd.js +1 -0
  9. package/dist/web/assets/Roles-KQ94PG3H.js +4 -0
  10. package/dist/web/assets/ScheduledTasks-CdJHJpEV.js +1 -0
  11. package/dist/web/assets/Settings-CTflMta-.js +1 -0
  12. package/dist/web/assets/Skills-D09W1mwX.js +2 -0
  13. package/dist/web/assets/Wizard-CW6B0wc3.js +1 -0
  14. package/dist/web/assets/WorkspaceChat-CthETL_A.js +1 -0
  15. package/dist/web/assets/WorkspaceDashboard-DTAesQuT.js +1 -0
  16. package/dist/web/assets/WorkspaceNew-Em4msIKn.js +1 -0
  17. package/dist/web/assets/WorkspaceProjects-Dxg2BpQy.js +1 -0
  18. package/dist/web/assets/WorkspaceTasks-C20mnnkP.js +1 -0
  19. package/dist/web/assets/index-B1k33vcR.js +11 -0
  20. package/dist/web/assets/index-Bk2hHz7P.css +1 -0
  21. package/dist/web/assets/index-Ddy5AJwx.js +61 -0
  22. package/dist/web/assets/useEventStream-DTID465I.js +1 -0
  23. package/dist/web/index.html +13 -0
  24. package/package.json +49 -6
  25. package/src/api/agents.ts +76 -0
  26. package/src/api/audit.ts +19 -0
  27. package/src/api/autopilot.ts +73 -0
  28. package/src/api/chat.ts +801 -0
  29. package/src/api/chief.ts +84 -0
  30. package/src/api/config.ts +39 -0
  31. package/src/api/gantt.ts +72 -0
  32. package/src/api/hooks.ts +54 -0
  33. package/src/api/inbox.ts +125 -0
  34. package/src/api/lark.ts +32 -0
  35. package/src/api/memory.ts +37 -0
  36. package/src/api/ops.ts +89 -0
  37. package/src/api/projects.ts +105 -0
  38. package/src/api/roles.ts +123 -0
  39. package/src/api/runtimes.ts +62 -0
  40. package/src/api/scheduled-tasks.ts +203 -0
  41. package/src/api/sessions.ts +479 -0
  42. package/src/api/skills.ts +386 -0
  43. package/src/api/tasks.ts +457 -0
  44. package/src/api/telegram.ts +94 -0
  45. package/src/api/templates.ts +36 -0
  46. package/src/api/webhooks.ts +20 -0
  47. package/src/api/workspaces.ts +150 -0
  48. package/src/bridges/lark/index.ts +213 -0
  49. package/src/bridges/telegram/index.ts +273 -0
  50. package/src/bridges/telegram/polling.ts +185 -0
  51. package/src/chat/index.ts +86 -0
  52. package/src/chief/index.ts +461 -0
  53. package/src/core/cli.ts +333 -0
  54. package/src/core/db.ts +53 -0
  55. package/src/core/event-bus.ts +33 -0
  56. package/src/core/index.ts +6 -0
  57. package/src/core/migrations.ts +303 -0
  58. package/src/core/router.ts +69 -0
  59. package/src/core/schema.sql +232 -0
  60. package/src/core/server.ts +308 -0
  61. package/src/core/validate.ts +22 -0
  62. package/src/discovery/index.ts +194 -0
  63. package/src/gateway/adapters/telegram.ts +148 -0
  64. package/src/gateway/index.ts +31 -0
  65. package/src/gateway/manager.ts +176 -0
  66. package/src/gateway/types.ts +77 -0
  67. package/src/inbox/index.ts +500 -0
  68. package/src/ops/artifact-sync.ts +65 -0
  69. package/src/ops/autopilot.ts +338 -0
  70. package/src/ops/gc.ts +252 -0
  71. package/src/ops/index.ts +226 -0
  72. package/src/ops/project-serial.ts +52 -0
  73. package/src/ops/role-dispatch.ts +111 -0
  74. package/src/ops/runtime-scheduler.ts +447 -0
  75. package/src/ops/task-blocking.ts +65 -0
  76. package/src/ops/task-deps.ts +37 -0
  77. package/src/ops/task-workspace.ts +60 -0
  78. package/src/roles/index.ts +258 -0
  79. package/src/roles/prompt-assembler.ts +85 -0
  80. package/src/roles/workspace-role.ts +155 -0
  81. package/src/scheduler/index.ts +461 -0
  82. package/src/session/output-parser.ts +75 -0
  83. package/src/session/realtime-parser.ts +40 -0
  84. package/src/skills/builtin.ts +155 -0
  85. package/src/skills/skill-extractor.ts +452 -0
  86. package/src/skills/skill-md.ts +282 -0
  87. package/src/transport/http-api.ts +75 -0
  88. package/src/transport/index.ts +4 -0
  89. package/src/transport/local-pty.ts +119 -0
  90. package/src/transport/ssh.ts +176 -0
  91. package/src/transport/types.ts +20 -0
  92. package/src/workflows/index.ts +231 -0
  93. package/index.js +0 -1
  94. package/maestro-agent-0.0.1.tgz +0 -0
@@ -0,0 +1,226 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { generateId, now } from "../core/db";
5
+
6
+ export function recoverInterruptedSessions(db: Database) {
7
+ const ts = now();
8
+ const running = db.query("SELECT * FROM session WHERE status = 'running'").all() as any[];
9
+ for (const session of running) {
10
+ db.run("UPDATE session SET status = 'interrupted', ended_at = ? WHERE id = ?", [ts, session.id]);
11
+ db.run("UPDATE agent SET status = 'idle', last_active_at = ? WHERE id = ?", [ts, session.agent_id]);
12
+ audit(db, "system", "ops.session_recovered", session.id, { previous_status: "running" });
13
+ }
14
+
15
+ // Recover stale orphaned tasks: tasks stuck in claimed/in_progress with no running session
16
+ const taskTimeoutMs = 5 * 60 * 1000;
17
+ const staleTaskCutoff = ts - taskTimeoutMs;
18
+ const orphanedTasks = db.query(`
19
+ SELECT t.id, t.status, t.assignee_agent_id FROM task t
20
+ WHERE t.status IN ('claimed', 'in_progress')
21
+ AND t.updated_at <= ?
22
+ AND NOT EXISTS (
23
+ SELECT 1 FROM session s
24
+ WHERE s.task_id = t.id AND s.status = 'running'
25
+ )
26
+ `).all(staleTaskCutoff) as any[];
27
+ for (const task of orphanedTasks) {
28
+ db.run(
29
+ "UPDATE task SET status = 'open', assignee_agent_id = NULL, claim_token = NULL, updated_at = ? WHERE id = ?",
30
+ [ts, task.id],
31
+ );
32
+ audit(db, "system", "ops.task_recovered", task.id, { previous_status: task.status, task_timeout_ms: taskTimeoutMs });
33
+ }
34
+
35
+ return { recovered: running.length, tasks_reset: orphanedTasks.length };
36
+ }
37
+
38
+ export function runtimeWaterline(db: Database, runtimeId: string, capacity: number) {
39
+ const running = (db.query(`
40
+ SELECT COUNT(*) AS count
41
+ FROM session
42
+ JOIN agent ON agent.id = session.agent_id
43
+ WHERE agent.runtime_id = ? AND session.status = 'running'
44
+ `).get(runtimeId) as any).count;
45
+ return {
46
+ running,
47
+ capacity,
48
+ utilization: capacity > 0 ? running / capacity : 0,
49
+ };
50
+ }
51
+
52
+ export async function checkRuntimeHealth(db: Database, runtime: any) {
53
+ let status = "online";
54
+ let detail = "ok";
55
+ if (runtime.transport === "local-pty" && runtime.cmd) {
56
+ const result = await Bun.spawn(["sh", "-c", `command -v ${shellQuote(runtime.cmd)}`]).exited;
57
+ status = result === 0 ? "online" : "offline";
58
+ detail = result === 0 ? "command found" : "command not found";
59
+ } else if ((runtime.transport === "ssh" || runtime.transport === "http-api") && !runtime.target) {
60
+ status = "offline";
61
+ detail = "missing target";
62
+ }
63
+ db.run("UPDATE agent_runtime SET status = ? WHERE id = ?", [status, runtime.id]);
64
+ return { id: runtime.id, status, detail, waterline: runtimeWaterline(db, runtime.id, Number(runtime.capacity ?? -1)) };
65
+ }
66
+
67
+ export function createBackup(hubDir: string) {
68
+ const backupDir = join(hubDir, "backups");
69
+ mkdirSync(backupDir, { recursive: true });
70
+ const source = join(hubDir, "maestro.db");
71
+ const path = join(backupDir, `maestro-${now()}.db`);
72
+ const db = new Database(source);
73
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
74
+ db.close();
75
+ copyFileSync(source, path);
76
+ return { path };
77
+ }
78
+
79
+ export function restoreBackup(hubDir: string, path: string) {
80
+ if (!existsSync(path)) throw new Error(`Backup not found: ${path}`);
81
+ copyFileSync(path, join(hubDir, "maestro.db"));
82
+ return { restored: true, path };
83
+ }
84
+
85
+ export function recordAgentExit(db: Database, input: { agentId: string; sessionId: string; code: number }) {
86
+ const agent = db.query("SELECT * FROM agent WHERE id = ?").get(input.agentId) as any;
87
+ if (!agent) return { restart: false, attempts: 0 };
88
+ const metrics = parseMetrics(agent.metrics_json);
89
+ const attempts = input.code === 0 ? 0 : Number(metrics.restart_attempts || 0) + 1;
90
+ metrics.restart_attempts = attempts;
91
+ db.run("UPDATE agent SET metrics_json = ? WHERE id = ?", [JSON.stringify(metrics), input.agentId]);
92
+
93
+ if (input.code !== 0 && attempts >= 3) {
94
+ createInbox(db, "escalation", "hub", "user", "Agent restart failed", `Agent ${input.agentId} crashed ${attempts} time(s).`, {
95
+ agent_id: input.agentId,
96
+ session_id: input.sessionId,
97
+ code: input.code,
98
+ });
99
+ return { restart: false, attempts };
100
+ }
101
+
102
+ return { restart: input.code !== 0, attempts };
103
+ }
104
+
105
+ export function opsStats(db: Database) {
106
+ const sessions = db.query("SELECT * FROM session").all() as any[];
107
+ const runtimes = (db.query("SELECT * FROM agent_runtime ORDER BY created_at DESC").all() as any[])
108
+ .map((runtime) => ({ ...runtime, waterline: runtimeWaterline(db, runtime.id, Number(runtime.capacity ?? -1)) }));
109
+
110
+ // Better cost estimation based on session duration
111
+ let totalDurationMs = 0;
112
+ for (const session of sessions) {
113
+ if (session.started_at && session.ended_at) {
114
+ totalDurationMs += session.ended_at - session.started_at;
115
+ }
116
+ }
117
+ const estimatedTokens = Math.max(sessions.length * 1000, Math.ceil(totalDurationMs / 60000) * 500);
118
+ const estimatedUsd = Math.round(estimatedTokens * 0.000015 * 100) / 100; // ~$15/1M tokens avg
119
+
120
+ return {
121
+ cost: {
122
+ sessions: sessions.length,
123
+ estimated_tokens: estimatedTokens,
124
+ estimated_usd: estimatedUsd,
125
+ total_duration_min: Math.round(totalDurationMs / 60000),
126
+ },
127
+ runtimes,
128
+ };
129
+ }
130
+
131
+ export async function smokeRuntimes(db: Database, runtimes: any[]) {
132
+ const ts = now();
133
+ let sessions = 0;
134
+ for (const runtime of runtimes) {
135
+ const runtimeId = generateId("runtime");
136
+ const agentId = generateId("agent");
137
+ const sessionId = generateId("sess");
138
+ db.run(
139
+ "INSERT INTO agent_runtime (id, type, transport, cmd, target, capacity, status, capabilities_json, created_at) VALUES (?, ?, ?, ?, ?, 1, 'online', '[]', ?)",
140
+ [runtimeId, runtime.type, runtime.transport, runtime.cmd || null, runtime.target || null, ts],
141
+ );
142
+ db.run(
143
+ "INSERT INTO agent (id, runtime_id, name, status, metrics_json, created_at, last_active_at) VALUES (?, ?, ?, 'idle', '{}', ?, ?)",
144
+ [agentId, runtimeId, `${runtime.type}-smoke`, ts, ts],
145
+ );
146
+ db.run(
147
+ "INSERT INTO session (id, agent_id, pty_pid, transcript_path, status, started_at, ended_at) VALUES (?, ?, 0, NULL, 'ended', ?, ?)",
148
+ [sessionId, agentId, ts, ts],
149
+ );
150
+ sessions++;
151
+ }
152
+ return { status: "passed", runtimes: runtimes.length, sessions };
153
+ }
154
+
155
+ export async function soakRuntimes(hubDir: string, db: Database, input: { runtimes: any[]; duration_ms?: number; fast_forward?: boolean }) {
156
+ const startedAt = now();
157
+ const smoke = await smokeRuntimes(db, input.runtimes || []);
158
+ const durationMs = Number(input.duration_ms ?? 24 * 60 * 60 * 1000);
159
+ if (durationMs > 0) await Bun.sleep(input.fast_forward ? Math.min(durationMs, 100) : durationMs);
160
+ const endedAt = now();
161
+ const observedDurationMs = endedAt - startedAt;
162
+ const complete = !input.fast_forward && observedDurationMs >= durationMs;
163
+ const evidenceDir = join(hubDir, "evidence");
164
+ mkdirSync(evidenceDir, { recursive: true });
165
+ const evidence = {
166
+ status: complete ? smoke.status : "simulated",
167
+ requested_duration_ms: durationMs,
168
+ observed_duration_ms: observedDurationMs,
169
+ fast_forward: Boolean(input.fast_forward),
170
+ complete,
171
+ started_at: startedAt,
172
+ ended_at: endedAt,
173
+ runtimes: input.runtimes || [],
174
+ sessions: smoke.sessions,
175
+ };
176
+ const evidencePath = join(evidenceDir, `soak-${startedAt}.json`);
177
+ writeFileSync(evidencePath, JSON.stringify(evidence, null, 2));
178
+ return {
179
+ ...smoke,
180
+ status: evidence.status,
181
+ duration_ms: durationMs,
182
+ observed_duration_ms: observedDurationMs,
183
+ complete,
184
+ evidence_path: evidencePath,
185
+ };
186
+ }
187
+
188
+ export function latestSoakEvidence(hubDir: string) {
189
+ const evidenceDir = join(hubDir, "evidence");
190
+ if (!existsSync(evidenceDir)) return null;
191
+ const files = readdirSync(evidenceDir)
192
+ .filter((file) => file.startsWith("soak-") && file.endsWith(".json"))
193
+ .sort();
194
+ const latest = files.at(-1);
195
+ if (!latest) return null;
196
+ const path = join(evidenceDir, latest);
197
+ return { path, ...JSON.parse(readFileSync(path, "utf-8")) };
198
+ }
199
+
200
+ function audit(db: Database, actor: string, action: string, target: string, payload: unknown) {
201
+ db.run(
202
+ "INSERT INTO audit_log (id, actor, action, target, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
203
+ [generateId("audit"), actor, action, target, JSON.stringify(payload), now()],
204
+ );
205
+ }
206
+
207
+ function createInbox(db: Database, kind: string, fromActor: string, toActor: string, subject: string, body: string, ref: unknown) {
208
+ db.run(
209
+ `INSERT INTO inbox_message (id, kind, from_actor, to_actor, subject, body, ref_json, status, created_at)
210
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', ?)`,
211
+ [generateId("msg"), kind, fromActor, toActor, subject, body, JSON.stringify(ref), now()],
212
+ );
213
+ }
214
+
215
+ function parseMetrics(raw: string | null | undefined) {
216
+ try {
217
+ return JSON.parse(raw || "{}");
218
+ } catch {
219
+ return {};
220
+ }
221
+ }
222
+
223
+ function shellQuote(value: string) {
224
+ if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) return value;
225
+ return `'${value.replace(/'/g, `'\\''`)}'`;
226
+ }
@@ -0,0 +1,52 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ export interface ProjectTaskRow {
4
+ id: string;
5
+ project_id?: string | null;
6
+ status?: string | null;
7
+ assignee_agent_id?: string | null;
8
+ updated_at?: number | null;
9
+ }
10
+
11
+ export function projectExecutionStatuses(
12
+ claimedStatus?: string | null,
13
+ ): string[] {
14
+ const statuses = new Set(["claimed", "in_progress"]);
15
+ if (claimedStatus) statuses.add(claimedStatus);
16
+ return [...statuses];
17
+ }
18
+
19
+ export function findProjectExecutionBlocker(
20
+ db: Database,
21
+ input: {
22
+ projectId?: string | null;
23
+ excludeTaskId?: string | null;
24
+ claimedStatus?: string | null;
25
+ },
26
+ ): ProjectTaskRow | null {
27
+ if (!input.projectId) return null;
28
+ const statuses = projectExecutionStatuses(input.claimedStatus);
29
+ const statusPlaceholders = statuses.map(() => "?").join(", ");
30
+ const excludeClause = input.excludeTaskId ? "AND id != ?" : "";
31
+ const params = input.excludeTaskId
32
+ ? [input.projectId, input.excludeTaskId, ...statuses]
33
+ : [input.projectId, ...statuses];
34
+
35
+ return db
36
+ .query(
37
+ `SELECT * FROM task
38
+ WHERE project_id = ?
39
+ ${excludeClause}
40
+ AND status IN (${statusPlaceholders})
41
+ ORDER BY updated_at ASC
42
+ LIMIT 1`,
43
+ )
44
+ .get(...params) as ProjectTaskRow | null;
45
+ }
46
+
47
+ export function isTaskAssignedToAgent(
48
+ task: ProjectTaskRow,
49
+ agentId?: string | null,
50
+ ): boolean {
51
+ return !task.assignee_agent_id || task.assignee_agent_id === agentId;
52
+ }
@@ -0,0 +1,111 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { now } from "../core/db";
3
+ import type { HubContext } from "../core/server";
4
+
5
+ /**
6
+ * Role-centric agent resolution.
7
+ *
8
+ * Principle: Tasks are assigned to Roles. A Role is always "online" (logical entity).
9
+ * An Agent is merely the execution tool. When a Role receives work, it picks the best
10
+ * available Agent — or wakes one up if all are offline.
11
+ */
12
+
13
+ export interface ResolvedAgent {
14
+ id: string;
15
+ woken: boolean; // true if the agent was offline and was reactivated
16
+ }
17
+
18
+ /**
19
+ * Find or wake an Agent under the given Role.
20
+ *
21
+ * Priority:
22
+ * 1. idle agent (no active session) — best candidate
23
+ * 2. idle agent (has running session but can accept more) — if capacity allows
24
+ * 3. offline agent — wake it up (set status to idle)
25
+ *
26
+ * Returns null only if the role has zero agents at all.
27
+ */
28
+ export function resolveAgentForRole(
29
+ db: Database,
30
+ roleId: string,
31
+ ): ResolvedAgent | null {
32
+ // 1. Find idle agent without a running session
33
+ const idleAgent = db.query(
34
+ `SELECT a.id FROM agent a
35
+ LEFT JOIN session s ON s.agent_id = a.id AND s.status = 'running'
36
+ WHERE a.role_id = ? AND a.status = 'idle' AND s.id IS NULL
37
+ ORDER BY a.last_active_at DESC
38
+ LIMIT 1`
39
+ ).get(roleId) as { id: string } | null;
40
+
41
+ if (idleAgent) return { id: idleAgent.id, woken: false };
42
+
43
+ // 2. Find idle agent that still has capacity (running sessions < capacity)
44
+ const idleWithCapacity = db.query(
45
+ `SELECT a.id FROM agent a
46
+ JOIN agent_runtime ar ON ar.id = a.runtime_id
47
+ LEFT JOIN (
48
+ SELECT agent_id, COUNT(*) as cnt FROM session WHERE status = 'running' GROUP BY agent_id
49
+ ) sc ON sc.agent_id = a.id
50
+ WHERE a.role_id = ? AND a.status = 'idle'
51
+ AND (ar.capacity < 0 OR COALESCE(sc.cnt, 0) < ar.capacity)
52
+ ORDER BY COALESCE(sc.cnt, 0) ASC, a.last_active_at DESC
53
+ LIMIT 1`
54
+ ).get(roleId) as { id: string } | null;
55
+
56
+ if (idleWithCapacity) return { id: idleWithCapacity.id, woken: false };
57
+
58
+ // 3. Wake an offline agent
59
+ const offlineAgent = db.query(
60
+ `SELECT id FROM agent WHERE role_id = ? AND status = 'offline' ORDER BY last_active_at DESC LIMIT 1`
61
+ ).get(roleId) as { id: string } | null;
62
+
63
+ if (offlineAgent) {
64
+ const ts = now();
65
+ db.run("UPDATE agent SET status = 'idle', last_active_at = ? WHERE id = ?", [ts, offlineAgent.id]);
66
+ return { id: offlineAgent.id, woken: true };
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Given a task (with optional required_capabilities), find the best Role in the workspace
74
+ * that can handle it, then resolve an Agent under that Role.
75
+ */
76
+ export function findRoleAndAgentForTask(
77
+ ctx: HubContext,
78
+ task: { project_id: string; required_capabilities_json?: string },
79
+ ): { role_id: string; agent: ResolvedAgent } | null {
80
+ const project = ctx.db.query("SELECT workspace_id FROM project WHERE id = ?").get(task.project_id) as any;
81
+ if (!project) return null;
82
+
83
+ const requiredCaps: string[] = safeJsonParse(task.required_capabilities_json);
84
+
85
+ // Find roles in this workspace that have agents
86
+ const roles = ctx.db.query(
87
+ `SELECT DISTINCT r.id, r.capabilities_json
88
+ FROM role r
89
+ JOIN agent a ON a.role_id = r.id
90
+ WHERE r.workspace_id = ?`
91
+ ).all(project.workspace_id) as Array<{ id: string; capabilities_json: string }>;
92
+
93
+ for (const role of roles) {
94
+ // Check capabilities match
95
+ if (requiredCaps.length > 0) {
96
+ const roleCaps: string[] = safeJsonParse(role.capabilities_json);
97
+ const hasAll = requiredCaps.every((cap) => roleCaps.includes(cap));
98
+ if (!hasAll) continue;
99
+ }
100
+
101
+ const agent = resolveAgentForRole(ctx.db, role.id);
102
+ if (agent) return { role_id: role.id, agent };
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ function safeJsonParse(val: string | null | undefined): string[] {
109
+ if (!val) return [];
110
+ try { return JSON.parse(val); } catch { return []; }
111
+ }