@sooneocean/agw 1.4.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 +116 -0
- package/bin/agw.ts +5 -0
- package/package.json +59 -0
- package/src/agents/base-adapter.ts +113 -0
- package/src/agents/claude-adapter.ts +29 -0
- package/src/agents/codex-adapter.ts +29 -0
- package/src/agents/gemini-adapter.ts +29 -0
- package/src/cli/commands/agents.ts +55 -0
- package/src/cli/commands/combo.ts +130 -0
- package/src/cli/commands/costs.ts +33 -0
- package/src/cli/commands/daemon.ts +110 -0
- package/src/cli/commands/history.ts +29 -0
- package/src/cli/commands/run.ts +59 -0
- package/src/cli/commands/status.ts +29 -0
- package/src/cli/commands/workflow.ts +73 -0
- package/src/cli/error-handler.ts +8 -0
- package/src/cli/http-client.ts +68 -0
- package/src/cli/index.ts +28 -0
- package/src/config.ts +68 -0
- package/src/daemon/middleware/auth.ts +35 -0
- package/src/daemon/middleware/rate-limiter.ts +63 -0
- package/src/daemon/middleware/tenant.ts +64 -0
- package/src/daemon/middleware/workspace.ts +40 -0
- package/src/daemon/routes/agents.ts +13 -0
- package/src/daemon/routes/combos.ts +103 -0
- package/src/daemon/routes/costs.ts +9 -0
- package/src/daemon/routes/health.ts +62 -0
- package/src/daemon/routes/memory.ts +32 -0
- package/src/daemon/routes/tasks.ts +157 -0
- package/src/daemon/routes/ui.ts +18 -0
- package/src/daemon/routes/workflows.ts +73 -0
- package/src/daemon/server.ts +91 -0
- package/src/daemon/services/agent-learning.ts +77 -0
- package/src/daemon/services/agent-manager.ts +71 -0
- package/src/daemon/services/auto-scaler.ts +77 -0
- package/src/daemon/services/circuit-breaker.ts +95 -0
- package/src/daemon/services/combo-executor.ts +300 -0
- package/src/daemon/services/dag-executor.ts +136 -0
- package/src/daemon/services/metrics.ts +35 -0
- package/src/daemon/services/stream-aggregator.ts +64 -0
- package/src/daemon/services/task-executor.ts +184 -0
- package/src/daemon/services/task-queue.ts +75 -0
- package/src/daemon/services/workflow-executor.ts +150 -0
- package/src/daemon/services/ws-manager.ts +90 -0
- package/src/dsl/parser.ts +124 -0
- package/src/plugins/plugin-loader.ts +72 -0
- package/src/router/keyword-router.ts +63 -0
- package/src/router/llm-router.ts +93 -0
- package/src/store/agent-repo.ts +57 -0
- package/src/store/audit-repo.ts +25 -0
- package/src/store/combo-repo.ts +99 -0
- package/src/store/cost-repo.ts +55 -0
- package/src/store/db.ts +137 -0
- package/src/store/memory-repo.ts +46 -0
- package/src/store/task-repo.ts +127 -0
- package/src/store/workflow-repo.ts +69 -0
- package/src/types.ts +208 -0
- package/tsconfig.json +17 -0
- package/ui/index.html +272 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
|
|
7
|
+
const AGW_DIR = path.join(os.homedir(), '.agw');
|
|
8
|
+
const PID_FILE = path.join(AGW_DIR, 'daemon.pid');
|
|
9
|
+
|
|
10
|
+
export function registerDaemonCommand(program: Command): void {
|
|
11
|
+
const cmd = program
|
|
12
|
+
.command('daemon')
|
|
13
|
+
.description('Manage the AGW daemon');
|
|
14
|
+
|
|
15
|
+
cmd
|
|
16
|
+
.command('start')
|
|
17
|
+
.description('Start the daemon')
|
|
18
|
+
.option('-d', 'Run as background daemon')
|
|
19
|
+
.option('--port <port>', 'Port to listen on')
|
|
20
|
+
.action(async (options: { d?: boolean; port?: string }) => {
|
|
21
|
+
if (isRunning()) {
|
|
22
|
+
console.log('Daemon is already running.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(AGW_DIR)) {
|
|
27
|
+
fs.mkdirSync(AGW_DIR, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (options.port) {
|
|
31
|
+
process.env.AGW_PORT = options.port;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (options.d) {
|
|
35
|
+
// Daemonize
|
|
36
|
+
const serverPath = path.resolve(import.meta.dirname, '../../daemon/server.js');
|
|
37
|
+
const child = spawn('tsx', [serverPath], {
|
|
38
|
+
detached: true,
|
|
39
|
+
stdio: 'ignore',
|
|
40
|
+
env: { ...process.env },
|
|
41
|
+
});
|
|
42
|
+
child.unref();
|
|
43
|
+
if (child.pid) {
|
|
44
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
45
|
+
console.log(`Daemon started (PID: ${child.pid})`);
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
// Foreground
|
|
49
|
+
console.log('Starting daemon in foreground...');
|
|
50
|
+
const { buildServer } = await import('../../daemon/server.js');
|
|
51
|
+
const { loadConfig } = await import('../../config.js');
|
|
52
|
+
const config = loadConfig(path.join(AGW_DIR, 'config.json'));
|
|
53
|
+
const port = options.port ? parseInt(options.port) : config.port;
|
|
54
|
+
const app = await buildServer();
|
|
55
|
+
await app.listen({ port, host: '127.0.0.1' });
|
|
56
|
+
console.log(`AGW daemon listening on http://127.0.0.1:${port}`);
|
|
57
|
+
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
58
|
+
|
|
59
|
+
const cleanup = async () => {
|
|
60
|
+
await app.close();
|
|
61
|
+
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
};
|
|
64
|
+
process.on('SIGTERM', cleanup);
|
|
65
|
+
process.on('SIGINT', cleanup);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
cmd
|
|
70
|
+
.command('stop')
|
|
71
|
+
.description('Stop the daemon')
|
|
72
|
+
.action(() => {
|
|
73
|
+
if (!fs.existsSync(PID_FILE)) {
|
|
74
|
+
console.log('No daemon running.');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8'));
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 'SIGTERM');
|
|
80
|
+
fs.unlinkSync(PID_FILE);
|
|
81
|
+
console.log(`Daemon stopped (PID: ${pid})`);
|
|
82
|
+
} catch {
|
|
83
|
+
fs.unlinkSync(PID_FILE);
|
|
84
|
+
console.log('Daemon was not running (stale PID file removed).');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
cmd
|
|
89
|
+
.command('status')
|
|
90
|
+
.description('Check daemon status')
|
|
91
|
+
.action(() => {
|
|
92
|
+
if (isRunning()) {
|
|
93
|
+
const pid = fs.readFileSync(PID_FILE, 'utf-8').trim();
|
|
94
|
+
console.log(`Daemon is running (PID: ${pid})`);
|
|
95
|
+
} else {
|
|
96
|
+
console.log('Daemon is not running.');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isRunning(): boolean {
|
|
102
|
+
if (!fs.existsSync(PID_FILE)) return false;
|
|
103
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8'));
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0); // Check if process exists
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { HttpClient } from '../http-client.js';
|
|
3
|
+
import type { TaskDescriptor } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export function registerHistoryCommand(program: Command): void {
|
|
6
|
+
program
|
|
7
|
+
.command('history')
|
|
8
|
+
.description('List recent tasks')
|
|
9
|
+
.option('--limit <n>', 'Number of tasks', '20')
|
|
10
|
+
.action(async (options: { limit: string }) => {
|
|
11
|
+
const client = new HttpClient();
|
|
12
|
+
try {
|
|
13
|
+
const tasks = await client.get<TaskDescriptor[]>(`/tasks?limit=${options.limit}`);
|
|
14
|
+
if (tasks.length === 0) {
|
|
15
|
+
console.log('No tasks yet.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
console.log('ID Status Agent Prompt');
|
|
19
|
+
console.log('─'.repeat(60));
|
|
20
|
+
for (const t of tasks) {
|
|
21
|
+
const prompt = t.prompt.length > 30 ? t.prompt.slice(0, 30) + '...' : t.prompt;
|
|
22
|
+
console.log(`${t.taskId} ${t.status.padEnd(10)} ${(t.assignedAgent ?? '-').padEnd(9)} ${prompt}`);
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { HttpClient } from '../http-client.js';
|
|
3
|
+
import type { TaskDescriptor } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export function registerRunCommand(program: Command): void {
|
|
6
|
+
program
|
|
7
|
+
.command('run <prompt...>')
|
|
8
|
+
.description('Submit a task to an agent')
|
|
9
|
+
.option('--agent <id>', 'Override agent selection')
|
|
10
|
+
.option('--background', 'Run in background, return taskId')
|
|
11
|
+
.option('--cwd <path>', 'Working directory for the agent')
|
|
12
|
+
.option('--priority <n>', 'Task priority 1-5 (default 3)', '3')
|
|
13
|
+
.action(async (promptParts: string[], options: { agent?: string; background?: boolean; cwd?: string; priority?: string }) => {
|
|
14
|
+
const client = new HttpClient();
|
|
15
|
+
let prompt = promptParts.join(' ');
|
|
16
|
+
|
|
17
|
+
// Read stdin if piped
|
|
18
|
+
if (!process.stdin.isTTY) {
|
|
19
|
+
const chunks: Buffer[] = [];
|
|
20
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
21
|
+
const stdin = Buffer.concat(chunks).toString();
|
|
22
|
+
if (stdin.trim()) prompt = `${prompt}\n\n${stdin}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const task = await client.post<TaskDescriptor>('/tasks', {
|
|
27
|
+
prompt,
|
|
28
|
+
preferredAgent: options.agent,
|
|
29
|
+
workingDirectory: options.cwd,
|
|
30
|
+
priority: parseInt(options.priority ?? '3', 10),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (options.background) {
|
|
34
|
+
console.log(`✓ Task submitted taskId: ${task.taskId}`);
|
|
35
|
+
console.log(` Check status: agw status ${task.taskId}`);
|
|
36
|
+
} else {
|
|
37
|
+
if (task.assignedAgent) {
|
|
38
|
+
console.log(`→ ${task.assignedAgent} (${task.routingReason ?? ''})`);
|
|
39
|
+
}
|
|
40
|
+
console.log('─'.repeat(40));
|
|
41
|
+
if (task.result) {
|
|
42
|
+
if (task.result.stdout) console.log(task.result.stdout);
|
|
43
|
+
if (task.result.stderr) console.error(task.result.stderr);
|
|
44
|
+
console.log('─'.repeat(40));
|
|
45
|
+
const tokens = task.result.tokenEstimate ? ` ~${task.result.tokenEstimate} tokens` : '';
|
|
46
|
+
const cost = task.result.costEstimate ? ` ~$${task.result.costEstimate.toFixed(3)}` : '';
|
|
47
|
+
const status = task.result.exitCode === 0 ? '✓ Done' : '✗ Failed';
|
|
48
|
+
console.log(`${status} ${(task.result.durationMs / 1000).toFixed(0)}s${tokens}${cost}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
53
|
+
if ((err as Error).message.includes('fetch failed') || (err as Error).message.includes('ECONNREFUSED')) {
|
|
54
|
+
console.error('Daemon not started. Run: agw daemon start');
|
|
55
|
+
}
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { HttpClient } from '../http-client.js';
|
|
3
|
+
import type { TaskDescriptor } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export function registerStatusCommand(program: Command): void {
|
|
6
|
+
program
|
|
7
|
+
.command('status <taskId>')
|
|
8
|
+
.description('Check task status')
|
|
9
|
+
.action(async (taskId: string) => {
|
|
10
|
+
const client = new HttpClient();
|
|
11
|
+
try {
|
|
12
|
+
const task = await client.get<TaskDescriptor>(`/tasks/${taskId}`);
|
|
13
|
+
console.log(`Task: ${task.taskId}`);
|
|
14
|
+
console.log(`Status: ${task.status}`);
|
|
15
|
+
console.log(`Agent: ${task.assignedAgent ?? 'not assigned'}`);
|
|
16
|
+
if (task.result) {
|
|
17
|
+
console.log(`Exit: ${task.result.exitCode}`);
|
|
18
|
+
console.log(`Time: ${(task.result.durationMs / 1000).toFixed(1)}s`);
|
|
19
|
+
if (task.result.stdout) {
|
|
20
|
+
console.log('─'.repeat(40));
|
|
21
|
+
console.log(task.result.stdout);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { HttpClient } from '../http-client.js';
|
|
3
|
+
import type { WorkflowDescriptor } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export function registerWorkflowCommand(program: Command): void {
|
|
6
|
+
const wf = program
|
|
7
|
+
.command('workflow')
|
|
8
|
+
.description('Manage multi-step workflows');
|
|
9
|
+
|
|
10
|
+
wf.command('run')
|
|
11
|
+
.description('Create and run a workflow from JSON')
|
|
12
|
+
.argument('<json>', 'Workflow JSON: { name, steps: [{ prompt, preferredAgent? }], mode? }')
|
|
13
|
+
.option('--cwd <path>', 'Working directory')
|
|
14
|
+
.option('--priority <n>', 'Priority 1-5', '3')
|
|
15
|
+
.action(async (json: string, options: { cwd?: string; priority?: string }) => {
|
|
16
|
+
const client = new HttpClient();
|
|
17
|
+
try {
|
|
18
|
+
const body = JSON.parse(json);
|
|
19
|
+
body.workingDirectory = options.cwd;
|
|
20
|
+
body.priority = parseInt(options.priority ?? '3', 10);
|
|
21
|
+
|
|
22
|
+
const wf = await client.post<WorkflowDescriptor>('/workflows', body);
|
|
23
|
+
console.log(`Workflow: ${wf.workflowId} ${wf.name}`);
|
|
24
|
+
console.log(`Status: ${wf.status}`);
|
|
25
|
+
console.log(`Steps: ${wf.steps.length} (${wf.mode})`);
|
|
26
|
+
console.log(`Tasks: ${wf.taskIds.join(', ') || 'none'}`);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
wf.command('status')
|
|
34
|
+
.description('Get workflow status')
|
|
35
|
+
.argument('<id>', 'Workflow ID')
|
|
36
|
+
.action(async (id: string) => {
|
|
37
|
+
const client = new HttpClient();
|
|
38
|
+
try {
|
|
39
|
+
const wf = await client.get<WorkflowDescriptor>(`/workflows/${id}`);
|
|
40
|
+
console.log(`Workflow: ${wf.workflowId}`);
|
|
41
|
+
console.log(`Name: ${wf.name}`);
|
|
42
|
+
console.log(`Mode: ${wf.mode}`);
|
|
43
|
+
console.log(`Status: ${wf.status}`);
|
|
44
|
+
console.log(`Progress: ${wf.currentStep + 1}/${wf.steps.length}`);
|
|
45
|
+
console.log(`Tasks: ${wf.taskIds.join(', ') || 'none'}`);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
wf.command('list')
|
|
53
|
+
.description('List workflows')
|
|
54
|
+
.option('--limit <n>', 'Number of workflows', '20')
|
|
55
|
+
.action(async (options: { limit?: string }) => {
|
|
56
|
+
const client = new HttpClient();
|
|
57
|
+
try {
|
|
58
|
+
const wfs = await client.get<WorkflowDescriptor[]>(`/workflows?limit=${options.limit ?? '20'}`);
|
|
59
|
+
if (wfs.length === 0) {
|
|
60
|
+
console.log('No workflows found.');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
console.log('ID Status Mode Name');
|
|
64
|
+
console.log('─'.repeat(60));
|
|
65
|
+
for (const w of wfs) {
|
|
66
|
+
console.log(`${w.workflowId} ${w.status.padEnd(10)} ${w.mode.padEnd(11)} ${w.name}`);
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function handleCliError(err: unknown): never {
|
|
2
|
+
const message = (err as Error).message;
|
|
3
|
+
console.error(`Error: ${message}`);
|
|
4
|
+
if (message.includes('fetch failed') || message.includes('ECONNREFUSED')) {
|
|
5
|
+
console.error('Daemon not started. Run: agw daemon start');
|
|
6
|
+
}
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const DEFAULT_BASE = 'http://127.0.0.1:4927';
|
|
2
|
+
|
|
3
|
+
export class HttpClient {
|
|
4
|
+
private authToken?: string;
|
|
5
|
+
|
|
6
|
+
constructor(private baseUrl: string = DEFAULT_BASE) {
|
|
7
|
+
this.authToken = process.env.AGW_AUTH_TOKEN;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
private headers(extra?: Record<string, string>): Record<string, string> {
|
|
11
|
+
const h: Record<string, string> = { ...extra };
|
|
12
|
+
if (this.authToken) h['Authorization'] = `Bearer ${this.authToken}`;
|
|
13
|
+
return h;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async post<T>(path: string, body: unknown): Promise<T> {
|
|
17
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: this.headers({ 'Content-Type': 'application/json' }),
|
|
20
|
+
body: JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
24
|
+
throw new Error((err as { error?: string }).error ?? `HTTP ${res.status}`);
|
|
25
|
+
}
|
|
26
|
+
return res.json() as Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async get<T>(path: string): Promise<T> {
|
|
30
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
31
|
+
headers: this.headers(),
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
35
|
+
throw new Error((err as { error?: string }).error ?? `HTTP ${res.status}`);
|
|
36
|
+
}
|
|
37
|
+
return res.json() as Promise<T>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async stream(path: string, onEvent: (event: string, data: string) => void): Promise<void> {
|
|
41
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
42
|
+
headers: this.headers({ Accept: 'text/event-stream' }),
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok || !res.body) {
|
|
45
|
+
throw new Error(`Stream failed: HTTP ${res.status}`);
|
|
46
|
+
}
|
|
47
|
+
const reader = res.body.getReader();
|
|
48
|
+
const decoder = new TextDecoder();
|
|
49
|
+
let buffer = '';
|
|
50
|
+
|
|
51
|
+
while (true) {
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done) break;
|
|
54
|
+
buffer += decoder.decode(value, { stream: true });
|
|
55
|
+
|
|
56
|
+
const parts = buffer.split('\n\n');
|
|
57
|
+
buffer = parts.pop() ?? '';
|
|
58
|
+
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
const eventMatch = part.match(/^event: (.+)$/m);
|
|
61
|
+
const dataMatch = part.match(/^data: (.+)$/m);
|
|
62
|
+
if (eventMatch && dataMatch) {
|
|
63
|
+
onEvent(eventMatch[1], dataMatch[1]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { registerRunCommand } from './commands/run.js';
|
|
3
|
+
import { registerStatusCommand } from './commands/status.js';
|
|
4
|
+
import { registerHistoryCommand } from './commands/history.js';
|
|
5
|
+
import { registerAgentsCommand } from './commands/agents.js';
|
|
6
|
+
import { registerDaemonCommand } from './commands/daemon.js';
|
|
7
|
+
import { registerCostsCommand } from './commands/costs.js';
|
|
8
|
+
import { registerWorkflowCommand } from './commands/workflow.js';
|
|
9
|
+
import { registerComboCommand } from './commands/combo.js';
|
|
10
|
+
|
|
11
|
+
export function createCli(): Command {
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name('agw')
|
|
15
|
+
.description('Agent Gateway — route tasks to the best AI agent')
|
|
16
|
+
.version('0.3.0');
|
|
17
|
+
|
|
18
|
+
registerRunCommand(program);
|
|
19
|
+
registerStatusCommand(program);
|
|
20
|
+
registerHistoryCommand(program);
|
|
21
|
+
registerAgentsCommand(program);
|
|
22
|
+
registerDaemonCommand(program);
|
|
23
|
+
registerCostsCommand(program);
|
|
24
|
+
registerWorkflowCommand(program);
|
|
25
|
+
registerComboCommand(program);
|
|
26
|
+
|
|
27
|
+
return program;
|
|
28
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import type { AppConfig, AgentConfig } from './types.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_AGENTS: Record<string, AgentConfig> = {
|
|
5
|
+
claude: { enabled: true, command: 'claude', args: [] },
|
|
6
|
+
codex: { enabled: true, command: 'codex', args: [] },
|
|
7
|
+
gemini: { enabled: true, command: 'gemini', args: [] },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const DEFAULTS: AppConfig = {
|
|
11
|
+
port: 4927,
|
|
12
|
+
anthropicApiKey: '',
|
|
13
|
+
routerModel: 'claude-haiku-4-5-20251001',
|
|
14
|
+
defaultTimeout: 300_000,
|
|
15
|
+
maxConcurrencyPerAgent: 3,
|
|
16
|
+
maxPromptLength: 100_000,
|
|
17
|
+
maxWorkflowSteps: 20,
|
|
18
|
+
agents: DEFAULT_AGENTS,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function loadConfig(configPath: string): AppConfig {
|
|
22
|
+
let fileConfig: Partial<AppConfig> = {};
|
|
23
|
+
|
|
24
|
+
if (fs.existsSync(configPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
27
|
+
fileConfig = JSON.parse(raw);
|
|
28
|
+
} catch {
|
|
29
|
+
// Ignore malformed config, use defaults
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const agents: Record<string, AgentConfig> = { ...DEFAULT_AGENTS };
|
|
34
|
+
if (fileConfig.agents) {
|
|
35
|
+
for (const [id, agentConf] of Object.entries(fileConfig.agents)) {
|
|
36
|
+
agents[id] = { ...DEFAULT_AGENTS[id], ...agentConf };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const port = process.env.AGW_PORT
|
|
41
|
+
? parseInt(process.env.AGW_PORT, 10)
|
|
42
|
+
: fileConfig.port ?? DEFAULTS.port;
|
|
43
|
+
|
|
44
|
+
const anthropicApiKey =
|
|
45
|
+
process.env.ANTHROPIC_API_KEY ??
|
|
46
|
+
fileConfig.anthropicApiKey ??
|
|
47
|
+
DEFAULTS.anthropicApiKey;
|
|
48
|
+
|
|
49
|
+
const authToken =
|
|
50
|
+
process.env.AGW_AUTH_TOKEN ??
|
|
51
|
+
fileConfig.authToken ??
|
|
52
|
+
undefined;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
port,
|
|
56
|
+
anthropicApiKey,
|
|
57
|
+
authToken,
|
|
58
|
+
routerModel: fileConfig.routerModel ?? DEFAULTS.routerModel,
|
|
59
|
+
defaultTimeout: fileConfig.defaultTimeout ?? DEFAULTS.defaultTimeout,
|
|
60
|
+
maxConcurrencyPerAgent: fileConfig.maxConcurrencyPerAgent ?? DEFAULTS.maxConcurrencyPerAgent,
|
|
61
|
+
dailyCostLimit: fileConfig.dailyCostLimit,
|
|
62
|
+
monthlyCostLimit: fileConfig.monthlyCostLimit,
|
|
63
|
+
allowedWorkspaces: fileConfig.allowedWorkspaces,
|
|
64
|
+
maxPromptLength: fileConfig.maxPromptLength ?? DEFAULTS.maxPromptLength,
|
|
65
|
+
maxWorkflowSteps: fileConfig.maxWorkflowSteps ?? DEFAULTS.maxWorkflowSteps,
|
|
66
|
+
agents,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
function safeCompare(a: string, b: string): boolean {
|
|
5
|
+
if (a.length !== b.length) return false;
|
|
6
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function registerAuthMiddleware(app: FastifyInstance, authToken?: string): void {
|
|
10
|
+
if (!authToken) {
|
|
11
|
+
// No auth — restrict to loopback only
|
|
12
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
13
|
+
const ip = request.ip;
|
|
14
|
+
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
|
15
|
+
if (!isLoopback) {
|
|
16
|
+
return reply.status(403).send({
|
|
17
|
+
error: 'Auth token required for non-loopback access. Set AGW_AUTH_TOKEN.',
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const expected = `Bearer ${authToken}`;
|
|
25
|
+
|
|
26
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
27
|
+
// Skip auth for Web UI static page (auth handled client-side via header)
|
|
28
|
+
if (request.url === '/ui') return;
|
|
29
|
+
|
|
30
|
+
const header = request.headers.authorization ?? '';
|
|
31
|
+
if (!safeCompare(header, expected)) {
|
|
32
|
+
return reply.status(401).send({ error: 'Unauthorized — invalid or missing token' });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
|
|
3
|
+
interface TokenBucket {
|
|
4
|
+
tokens: number;
|
|
5
|
+
lastRefill: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RateLimitConfig {
|
|
9
|
+
maxRequests: number; // tokens per window
|
|
10
|
+
windowMs: number; // refill interval in ms
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG: RateLimitConfig = { maxRequests: 60, windowMs: 60_000 };
|
|
14
|
+
|
|
15
|
+
export class RateLimiter {
|
|
16
|
+
private buckets = new Map<string, TokenBucket>();
|
|
17
|
+
private config: RateLimitConfig;
|
|
18
|
+
|
|
19
|
+
constructor(config?: Partial<RateLimitConfig>) {
|
|
20
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
check(clientId: string): { allowed: boolean; remaining: number; resetMs: number } {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
let bucket = this.buckets.get(clientId);
|
|
26
|
+
|
|
27
|
+
if (!bucket) {
|
|
28
|
+
bucket = { tokens: this.config.maxRequests, lastRefill: now };
|
|
29
|
+
this.buckets.set(clientId, bucket);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Refill
|
|
33
|
+
const elapsed = now - bucket.lastRefill;
|
|
34
|
+
if (elapsed >= this.config.windowMs) {
|
|
35
|
+
bucket.tokens = this.config.maxRequests;
|
|
36
|
+
bucket.lastRefill = now;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (bucket.tokens > 0) {
|
|
40
|
+
bucket.tokens--;
|
|
41
|
+
return { allowed: true, remaining: bucket.tokens, resetMs: this.config.windowMs - (now - bucket.lastRefill) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { allowed: false, remaining: 0, resetMs: this.config.windowMs - (now - bucket.lastRefill) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function registerRateLimiter(app: FastifyInstance, config?: Partial<RateLimitConfig>): RateLimiter {
|
|
49
|
+
const limiter = new RateLimiter(config);
|
|
50
|
+
|
|
51
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
52
|
+
if (request.method === 'GET') return; // Don't rate-limit reads
|
|
53
|
+
const clientId = request.ip;
|
|
54
|
+
const { allowed, remaining, resetMs } = limiter.check(clientId);
|
|
55
|
+
reply.header('X-RateLimit-Remaining', remaining);
|
|
56
|
+
reply.header('X-RateLimit-Reset', Math.ceil(resetMs / 1000));
|
|
57
|
+
if (!allowed) {
|
|
58
|
+
return reply.status(429).send({ error: 'Rate limit exceeded', retryAfterMs: resetMs });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return limiter;
|
|
63
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export interface Tenant {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
quotaDailyLimit?: number;
|
|
9
|
+
quotaMonthlyLimit?: number;
|
|
10
|
+
allowedAgents?: string[];
|
|
11
|
+
maxConcurrency?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare module 'fastify' {
|
|
15
|
+
interface FastifyRequest {
|
|
16
|
+
tenant?: Tenant;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class TenantManager {
|
|
21
|
+
private tenants = new Map<string, Tenant>();
|
|
22
|
+
private keyIndex = new Map<string, string>(); // hashedKey → tenantId
|
|
23
|
+
|
|
24
|
+
addTenant(tenant: Tenant): void {
|
|
25
|
+
this.tenants.set(tenant.id, tenant);
|
|
26
|
+
const hash = createHash('sha256').update(tenant.apiKey).digest('hex');
|
|
27
|
+
this.keyIndex.set(hash, tenant.id);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
removeTenant(id: string): void {
|
|
31
|
+
const tenant = this.tenants.get(id);
|
|
32
|
+
if (tenant) {
|
|
33
|
+
const hash = createHash('sha256').update(tenant.apiKey).digest('hex');
|
|
34
|
+
this.keyIndex.delete(hash);
|
|
35
|
+
this.tenants.delete(id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resolveByApiKey(apiKey: string): Tenant | undefined {
|
|
40
|
+
const hash = createHash('sha256').update(apiKey).digest('hex');
|
|
41
|
+
const tenantId = this.keyIndex.get(hash);
|
|
42
|
+
return tenantId ? this.tenants.get(tenantId) : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getTenant(id: string): Tenant | undefined {
|
|
46
|
+
return this.tenants.get(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
listTenants(): Tenant[] {
|
|
50
|
+
return Array.from(this.tenants.values()).map(t => ({ ...t, apiKey: '***' }));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function registerTenantMiddleware(app: FastifyInstance, tenantManager: TenantManager): void {
|
|
55
|
+
if (tenantManager.listTenants().length === 0) return;
|
|
56
|
+
|
|
57
|
+
app.addHook('onRequest', async (request: FastifyRequest) => {
|
|
58
|
+
const header = request.headers.authorization;
|
|
59
|
+
if (!header?.startsWith('Bearer ')) return;
|
|
60
|
+
const key = header.slice(7);
|
|
61
|
+
const tenant = tenantManager.resolveByApiKey(key);
|
|
62
|
+
if (tenant) request.tenant = tenant;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function validateWorkspace(workingDirectory: string | undefined, allowedWorkspaces?: string[]): string {
|
|
5
|
+
const resolved = workingDirectory ?? process.cwd();
|
|
6
|
+
|
|
7
|
+
// Resolve to real path (follows symlinks, canonicalizes)
|
|
8
|
+
let realDir: string;
|
|
9
|
+
try {
|
|
10
|
+
realDir = fs.realpathSync(resolved);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error(`Working directory does not exist: ${resolved}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Verify it's a directory
|
|
16
|
+
if (!fs.statSync(realDir).isDirectory()) {
|
|
17
|
+
throw new Error(`Not a directory: ${resolved}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// If no allowedWorkspaces configured, allow any local directory
|
|
21
|
+
if (!allowedWorkspaces || allowedWorkspaces.length === 0) return realDir;
|
|
22
|
+
|
|
23
|
+
// Check if realDir is under any allowed workspace
|
|
24
|
+
for (const allowed of allowedWorkspaces) {
|
|
25
|
+
let realAllowed: string;
|
|
26
|
+
try {
|
|
27
|
+
realAllowed = fs.realpathSync(allowed);
|
|
28
|
+
} catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (realDir === realAllowed || realDir.startsWith(realAllowed + path.sep)) {
|
|
32
|
+
return realDir;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Working directory ${resolved} is outside allowed workspaces. ` +
|
|
38
|
+
`Allowed: ${allowedWorkspaces.join(', ')}`
|
|
39
|
+
);
|
|
40
|
+
}
|