@sooneocean/agw 1.4.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sooneocean/agw",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Agent Gateway — multi-agent task router for Claude, Codex, Gemini with combos, DAG, DSL",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,54 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { TemplateEngine } from '../services/template-engine.js';
3
+ import type { WebhookManager } from '../services/webhook-manager.js';
4
+ import type { Scheduler } from '../services/scheduler.js';
5
+ import type { MemoryRepo } from '../../store/memory-repo.js';
6
+ import { COMBO_PRESETS } from '../services/combo-executor.js';
7
+ import { createExport, validateImport } from '../services/export-import.js';
8
+
9
+ export function registerExportImportRoutes(
10
+ app: FastifyInstance,
11
+ templateEngine: TemplateEngine,
12
+ webhookManager: WebhookManager,
13
+ scheduler: Scheduler,
14
+ memoryRepo: MemoryRepo,
15
+ ): void {
16
+ app.get('/export', async () => {
17
+ return createExport({
18
+ templates: templateEngine.list(),
19
+ comboPresets: COMBO_PRESETS,
20
+ webhooks: webhookManager.getWebhooks(),
21
+ memory: memoryRepo.list(1000),
22
+ scheduledJobs: scheduler.listJobs(),
23
+ version: '1.6.0',
24
+ });
25
+ });
26
+
27
+ app.post('/import', async (request, reply) => {
28
+ const data = request.body;
29
+ if (!validateImport(data)) {
30
+ return reply.status(400).send({ error: 'Invalid import format' });
31
+ }
32
+
33
+ let imported = { templates: 0, memory: 0, jobs: 0 };
34
+
35
+ for (const t of data.templates) {
36
+ templateEngine.register(t);
37
+ imported.templates++;
38
+ }
39
+
40
+ for (const m of data.memory) {
41
+ memoryRepo.set(m.key, m.value, m.scope);
42
+ imported.memory++;
43
+ }
44
+
45
+ for (const j of data.scheduledJobs) {
46
+ try {
47
+ scheduler.addJob(j);
48
+ imported.jobs++;
49
+ } catch { /* skip invalid */ }
50
+ }
51
+
52
+ return { imported };
53
+ });
54
+ }
@@ -0,0 +1,22 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { ReplayManager } from '../services/replay.js';
3
+
4
+ export function registerReplayRoutes(app: FastifyInstance, replayManager: ReplayManager): void {
5
+ app.post<{ Params: { id: string } }>('/tasks/:id/replay', async (request, reply) => {
6
+ try {
7
+ const task = await replayManager.replayTask(request.params.id);
8
+ return reply.status(201).send(task);
9
+ } catch (err) {
10
+ return reply.status(404).send({ error: (err as Error).message });
11
+ }
12
+ });
13
+
14
+ app.post<{ Params: { id: string } }>('/combos/:id/replay', async (request, reply) => {
15
+ try {
16
+ const comboId = replayManager.replayCombo(request.params.id);
17
+ return reply.status(202).send({ comboId, status: 'replaying' });
18
+ } catch (err) {
19
+ return reply.status(404).send({ error: (err as Error).message });
20
+ }
21
+ });
22
+ }
@@ -0,0 +1,38 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { Scheduler } from '../services/scheduler.js';
3
+
4
+ export function registerSchedulerRoutes(app: FastifyInstance, scheduler: Scheduler): void {
5
+ app.get('/scheduler/jobs', async () => scheduler.listJobs());
6
+
7
+ app.get<{ Params: { id: string } }>('/scheduler/jobs/:id', async (request, reply) => {
8
+ const job = scheduler.getJob(request.params.id);
9
+ if (!job) return reply.status(404).send({ error: 'Job not found' });
10
+ return job;
11
+ });
12
+
13
+ app.post('/scheduler/jobs', async (request, reply) => {
14
+ const body = request.body as any;
15
+ try {
16
+ const job = scheduler.addJob(body);
17
+ return reply.status(201).send(job);
18
+ } catch (err) {
19
+ return reply.status(400).send({ error: (err as Error).message });
20
+ }
21
+ });
22
+
23
+ app.delete<{ Params: { id: string } }>('/scheduler/jobs/:id', async (request, reply) => {
24
+ const removed = scheduler.removeJob(request.params.id);
25
+ if (!removed) return reply.status(404).send({ error: 'Job not found' });
26
+ return { removed: true };
27
+ });
28
+
29
+ app.post<{ Params: { id: string } }>('/scheduler/jobs/:id/enable', async (request, reply) => {
30
+ if (!scheduler.enableJob(request.params.id)) return reply.status(404).send({ error: 'Job not found' });
31
+ return { enabled: true };
32
+ });
33
+
34
+ app.post<{ Params: { id: string } }>('/scheduler/jobs/:id/disable', async (request, reply) => {
35
+ if (!scheduler.disableJob(request.params.id)) return reply.status(404).send({ error: 'Job not found' });
36
+ return { disabled: true };
37
+ });
38
+ }
@@ -0,0 +1,60 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { TemplateEngine, TaskTemplate, InstantiateRequest } from '../services/template-engine.js';
3
+ import type { TaskExecutor } from '../services/task-executor.js';
4
+ import type { LlmRouter } from '../../router/llm-router.js';
5
+ import type { AgentManager } from '../services/agent-manager.js';
6
+
7
+ export function registerTemplateRoutes(
8
+ app: FastifyInstance,
9
+ templateEngine: TemplateEngine,
10
+ executor: TaskExecutor,
11
+ router: LlmRouter,
12
+ agentManager: AgentManager,
13
+ ): void {
14
+ // List templates
15
+ app.get<{ Querystring: { tag?: string } }>('/templates', async (request) => {
16
+ return templateEngine.list(request.query.tag);
17
+ });
18
+
19
+ // Get template
20
+ app.get<{ Params: { id: string } }>('/templates/:id', async (request, reply) => {
21
+ const t = templateEngine.get(request.params.id);
22
+ if (!t) return reply.status(404).send({ error: 'Template not found' });
23
+ return t;
24
+ });
25
+
26
+ // Register custom template
27
+ app.post<{ Body: TaskTemplate }>('/templates', async (request, reply) => {
28
+ templateEngine.register(request.body);
29
+ return reply.status(201).send(request.body);
30
+ });
31
+
32
+ // Delete template
33
+ app.delete<{ Params: { id: string } }>('/templates/:id', async (request, reply) => {
34
+ const deleted = templateEngine.unregister(request.params.id);
35
+ if (!deleted) return reply.status(404).send({ error: 'Template not found' });
36
+ return { deleted: true };
37
+ });
38
+
39
+ // Instantiate and execute a template
40
+ app.post<{ Body: InstantiateRequest }>('/templates/execute', async (request, reply) => {
41
+ try {
42
+ const { prompt, agent, priority } = templateEngine.instantiate(request.body);
43
+ const availableAgents = agentManager.getAvailableAgents();
44
+
45
+ const task = await executor.execute(
46
+ {
47
+ prompt,
48
+ preferredAgent: agent ?? request.body.overrides?.agent,
49
+ workingDirectory: request.body.overrides?.workingDirectory,
50
+ priority,
51
+ },
52
+ async (p) => router.route(p, availableAgents, agent),
53
+ );
54
+
55
+ return reply.status(201).send(task);
56
+ } catch (err) {
57
+ return reply.status(400).send({ error: (err as Error).message });
58
+ }
59
+ });
60
+ }
@@ -0,0 +1,18 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { WebhookManager, WebhookConfig } from '../services/webhook-manager.js';
3
+
4
+ export function registerWebhookRoutes(app: FastifyInstance, webhookManager: WebhookManager): void {
5
+ app.get('/webhooks', async () => {
6
+ return webhookManager.getWebhooks();
7
+ });
8
+
9
+ app.post<{ Body: WebhookConfig }>('/webhooks', async (request, reply) => {
10
+ webhookManager.addWebhook(request.body);
11
+ return reply.status(201).send({ registered: true, url: request.body.url });
12
+ });
13
+
14
+ app.delete<{ Body: { url: string } }>('/webhooks', async (request) => {
15
+ webhookManager.removeWebhook(request.body.url);
16
+ return { removed: true };
17
+ });
18
+ }
@@ -10,22 +10,31 @@ import { AuditRepo } from '../store/audit-repo.js';
10
10
  import { CostRepo } from '../store/cost-repo.js';
11
11
  import { WorkflowRepo } from '../store/workflow-repo.js';
12
12
  import { ComboRepo } from '../store/combo-repo.js';
13
+ import { MemoryRepo } from '../store/memory-repo.js';
13
14
  import { AgentManager } from './services/agent-manager.js';
14
15
  import { TaskExecutor } from './services/task-executor.js';
15
16
  import { WorkflowExecutor } from './services/workflow-executor.js';
16
17
  import { ComboExecutor } from './services/combo-executor.js';
17
18
  import { LlmRouter } from '../router/llm-router.js';
19
+ import { MetricsCollector } from './services/metrics.js';
20
+ import { CircuitBreakerRegistry } from './services/circuit-breaker.js';
21
+ import { TemplateEngine } from './services/template-engine.js';
22
+ import { WebhookManager } from './services/webhook-manager.js';
18
23
  import { registerAuthMiddleware } from './middleware/auth.js';
19
24
  import { registerAgentRoutes } from './routes/agents.js';
20
25
  import { registerTaskRoutes } from './routes/tasks.js';
21
26
  import { registerWorkflowRoutes } from './routes/workflows.js';
22
27
  import { registerCostRoutes } from './routes/costs.js';
23
28
  import { registerComboRoutes } from './routes/combos.js';
24
- import { MemoryRepo } from '../store/memory-repo.js';
25
29
  import { registerMemoryRoutes } from './routes/memory.js';
26
30
  import { registerHealthRoutes } from './routes/health.js';
27
- import { MetricsCollector } from './services/metrics.js';
28
- import { CircuitBreakerRegistry } from './services/circuit-breaker.js';
31
+ import { registerTemplateRoutes } from './routes/templates.js';
32
+ import { registerWebhookRoutes } from './routes/webhooks.js';
33
+ import { Scheduler } from './services/scheduler.js';
34
+ import { ReplayManager } from './services/replay.js';
35
+ import { registerSchedulerRoutes } from './routes/scheduler.js';
36
+ import { registerReplayRoutes } from './routes/replay.js';
37
+ import { registerExportImportRoutes } from './routes/export-import.js';
29
38
 
30
39
  interface ServerOptions {
31
40
  dbPath?: string;
@@ -58,6 +67,11 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
58
67
  const comboExecutor = new ComboExecutor(comboRepo, auditRepo, executor, agentManager);
59
68
  const metrics = new MetricsCollector();
60
69
  const cbRegistry = new CircuitBreakerRegistry();
70
+ const templateEngine = new TemplateEngine();
71
+ templateEngine.seedDefaults();
72
+ const webhookManager = new WebhookManager();
73
+ const scheduler = new Scheduler();
74
+ const replayManager = new ReplayManager(taskRepo, comboRepo, executor, comboExecutor, router, agentManager);
61
75
 
62
76
  const app = Fastify({
63
77
  logger: false,
@@ -73,6 +87,11 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
73
87
  registerComboRoutes(app, comboExecutor, config);
74
88
  registerMemoryRoutes(app, memoryRepo);
75
89
  registerHealthRoutes(app, metrics, agentManager, cbRegistry, taskRepo, costRepo, config);
90
+ registerTemplateRoutes(app, templateEngine, executor, router, agentManager);
91
+ registerWebhookRoutes(app, webhookManager);
92
+ registerSchedulerRoutes(app, scheduler);
93
+ registerReplayRoutes(app, replayManager);
94
+ registerExportImportRoutes(app, templateEngine, webhookManager, scheduler, memoryRepo);
76
95
 
77
96
  app.register(import('./routes/ui.js'));
78
97
 
@@ -84,6 +103,7 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
84
103
  taskRepo.updateStatus(t.taskId, 'failed');
85
104
  auditRepo.log(t.taskId, 'task.failed', { reason: 'daemon shutdown' });
86
105
  }
106
+ scheduler.stopAll();
87
107
  db.close();
88
108
  });
89
109
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Condition Engine — evaluates conditions on agent output to control combo flow.
3
+ *
4
+ * Conditions:
5
+ * contains:"APPROVED" — output contains string
6
+ * !contains:"ERROR" — output does NOT contain string
7
+ * exitCode:0 — task exit code equals value
8
+ * length>100 — output length greater than threshold
9
+ * matches:/pattern/i — output matches regex
10
+ * always — always true (default)
11
+ */
12
+
13
+ export interface Condition {
14
+ type: 'contains' | 'not-contains' | 'exitCode' | 'length-gt' | 'length-lt' | 'matches' | 'always';
15
+ value?: string | number;
16
+ }
17
+
18
+ export interface ConditionalBranch {
19
+ condition: Condition;
20
+ thenStep: number; // step index to jump to
21
+ elseStep?: number; // step index if condition is false
22
+ }
23
+
24
+ export function parseCondition(expr: string): Condition {
25
+ if (expr === 'always') return { type: 'always' };
26
+
27
+ const containsMatch = expr.match(/^contains:"(.+)"$/);
28
+ if (containsMatch) return { type: 'contains', value: containsMatch[1] };
29
+
30
+ const notContainsMatch = expr.match(/^!contains:"(.+)"$/);
31
+ if (notContainsMatch) return { type: 'not-contains', value: notContainsMatch[1] };
32
+
33
+ const exitCodeMatch = expr.match(/^exitCode:(\d+)$/);
34
+ if (exitCodeMatch) return { type: 'exitCode', value: parseInt(exitCodeMatch[1], 10) };
35
+
36
+ const lengthGtMatch = expr.match(/^length>(\d+)$/);
37
+ if (lengthGtMatch) return { type: 'length-gt', value: parseInt(lengthGtMatch[1], 10) };
38
+
39
+ const lengthLtMatch = expr.match(/^length<(\d+)$/);
40
+ if (lengthLtMatch) return { type: 'length-lt', value: parseInt(lengthLtMatch[1], 10) };
41
+
42
+ const matchesMatch = expr.match(/^matches:\/(.+)\/([gimsuy]*)$/);
43
+ if (matchesMatch) return { type: 'matches', value: matchesMatch[1] + (matchesMatch[2] ? `/${matchesMatch[2]}` : '') };
44
+
45
+ throw new Error(`Invalid condition: ${expr}`);
46
+ }
47
+
48
+ export function evaluateCondition(condition: Condition, output: string, exitCode: number = 0): boolean {
49
+ switch (condition.type) {
50
+ case 'always':
51
+ return true;
52
+
53
+ case 'contains':
54
+ return output.includes(String(condition.value));
55
+
56
+ case 'not-contains':
57
+ return !output.includes(String(condition.value));
58
+
59
+ case 'exitCode':
60
+ return exitCode === condition.value;
61
+
62
+ case 'length-gt':
63
+ return output.length > (condition.value as number);
64
+
65
+ case 'length-lt':
66
+ return output.length < (condition.value as number);
67
+
68
+ case 'matches': {
69
+ const parts = String(condition.value).split('/');
70
+ const pattern = parts[0];
71
+ const flags = parts[1] ?? '';
72
+ return new RegExp(pattern, flags).test(output);
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Export/Import — serialize AGW configuration and data for sharing/backup.
3
+ *
4
+ * Exports: templates, combo presets, webhooks, memory entries, scheduled jobs.
5
+ * Does NOT export: tasks, audit logs, cost records (runtime data).
6
+ */
7
+
8
+ import type { TaskTemplate } from './template-engine.js';
9
+ import type { WebhookConfig } from './webhook-manager.js';
10
+ import type { ScheduledJob } from './scheduler.js';
11
+ import type { MemoryEntry } from '../../store/memory-repo.js';
12
+ import type { ComboPreset } from '../../types.js';
13
+
14
+ export interface AgwExport {
15
+ version: string;
16
+ exportedAt: string;
17
+ templates: TaskTemplate[];
18
+ comboPresets: ComboPreset[];
19
+ webhooks: WebhookConfig[];
20
+ memory: MemoryEntry[];
21
+ scheduledJobs: Omit<ScheduledJob, 'id' | 'intervalMs' | 'nextRun' | 'runCount' | 'lastRun'>[];
22
+ }
23
+
24
+ export function createExport(data: {
25
+ templates: TaskTemplate[];
26
+ comboPresets: ComboPreset[];
27
+ webhooks: WebhookConfig[];
28
+ memory: MemoryEntry[];
29
+ scheduledJobs: ScheduledJob[];
30
+ version: string;
31
+ }): AgwExport {
32
+ return {
33
+ version: data.version,
34
+ exportedAt: new Date().toISOString(),
35
+ templates: data.templates,
36
+ comboPresets: data.comboPresets,
37
+ webhooks: data.webhooks.map(w => ({ ...w, secret: undefined })), // Strip secrets
38
+ memory: data.memory,
39
+ scheduledJobs: data.scheduledJobs.map(j => ({
40
+ name: j.name,
41
+ type: j.type,
42
+ target: j.target,
43
+ params: j.params,
44
+ interval: j.interval,
45
+ agent: j.agent,
46
+ priority: j.priority,
47
+ workingDirectory: j.workingDirectory,
48
+ enabled: j.enabled,
49
+ })),
50
+ };
51
+ }
52
+
53
+ export function validateImport(data: unknown): data is AgwExport {
54
+ if (!data || typeof data !== 'object') return false;
55
+ const d = data as Record<string, unknown>;
56
+ return (
57
+ typeof d.version === 'string' &&
58
+ typeof d.exportedAt === 'string' &&
59
+ Array.isArray(d.templates) &&
60
+ Array.isArray(d.memory)
61
+ );
62
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Replay — re-run a completed/failed task or combo with the same parameters.
3
+ */
4
+
5
+ import type { TaskDescriptor, ComboDescriptor, CreateComboRequest } from '../../types.js';
6
+ import type { TaskExecutor } from './task-executor.js';
7
+ import type { ComboExecutor } from './combo-executor.js';
8
+ import type { TaskRepo } from '../../store/task-repo.js';
9
+ import type { ComboRepo } from '../../store/combo-repo.js';
10
+ import type { LlmRouter } from '../../router/llm-router.js';
11
+ import type { AgentManager } from './agent-manager.js';
12
+
13
+ export class ReplayManager {
14
+ constructor(
15
+ private taskRepo: TaskRepo,
16
+ private comboRepo: ComboRepo,
17
+ private taskExecutor: TaskExecutor,
18
+ private comboExecutor: ComboExecutor,
19
+ private router: LlmRouter,
20
+ private agentManager: AgentManager,
21
+ ) {}
22
+
23
+ async replayTask(taskId: string): Promise<TaskDescriptor> {
24
+ const original = this.taskRepo.getById(taskId);
25
+ if (!original) throw new Error(`Task ${taskId} not found`);
26
+
27
+ const availableAgents = this.agentManager.getAvailableAgents();
28
+ return this.taskExecutor.execute(
29
+ {
30
+ prompt: original.prompt,
31
+ preferredAgent: original.assignedAgent,
32
+ workingDirectory: original.workingDirectory,
33
+ priority: original.priority,
34
+ },
35
+ async (p) => this.router.route(p, availableAgents, original.assignedAgent),
36
+ );
37
+ }
38
+
39
+ replayCombo(comboId: string): string {
40
+ const original = this.comboRepo.getById(comboId);
41
+ if (!original) throw new Error(`Combo ${comboId} not found`);
42
+
43
+ const request: CreateComboRequest = {
44
+ name: `${original.name} (replay)`,
45
+ pattern: original.pattern,
46
+ steps: original.steps,
47
+ input: original.input,
48
+ maxIterations: original.maxIterations,
49
+ };
50
+
51
+ return this.comboExecutor.start(request);
52
+ }
53
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Scheduler — run tasks, combos, or templates on cron-like intervals.
3
+ *
4
+ * Supports: interval-based scheduling (every N minutes/hours).
5
+ * Cron expressions are parsed as simple patterns: "every 5m", "every 1h", "every 30s".
6
+ */
7
+
8
+ import { nanoid } from 'nanoid';
9
+ import { EventEmitter } from 'node:events';
10
+
11
+ export interface ScheduledJob {
12
+ id: string;
13
+ name: string;
14
+ type: 'task' | 'combo-preset' | 'template';
15
+ target: string; // prompt for task, presetId for combo, templateId for template
16
+ params?: Record<string, string>; // for templates
17
+ interval: string; // "every 5m", "every 1h"
18
+ intervalMs: number; // parsed interval in ms
19
+ agent?: string;
20
+ priority?: number;
21
+ workingDirectory?: string;
22
+ enabled: boolean;
23
+ lastRun?: string;
24
+ nextRun: string;
25
+ runCount: number;
26
+ }
27
+
28
+ export function parseInterval(expr: string): number {
29
+ const match = expr.match(/^every\s+(\d+)\s*(s|m|h|d)$/i);
30
+ if (!match) throw new Error(`Invalid interval: "${expr}". Use "every Ns/m/h/d"`);
31
+
32
+ const value = parseInt(match[1], 10);
33
+ const unit = match[2].toLowerCase();
34
+ const multipliers: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
35
+ return value * multipliers[unit];
36
+ }
37
+
38
+ export class Scheduler extends EventEmitter {
39
+ private jobs = new Map<string, ScheduledJob>();
40
+ private timers = new Map<string, NodeJS.Timeout>();
41
+
42
+ addJob(job: Omit<ScheduledJob, 'id' | 'intervalMs' | 'nextRun' | 'runCount'>): ScheduledJob {
43
+ const id = nanoid(8);
44
+ const intervalMs = parseInterval(job.interval);
45
+ const scheduled: ScheduledJob = {
46
+ ...job,
47
+ id,
48
+ intervalMs,
49
+ nextRun: new Date(Date.now() + intervalMs).toISOString(),
50
+ runCount: 0,
51
+ };
52
+
53
+ this.jobs.set(id, scheduled);
54
+
55
+ if (scheduled.enabled) {
56
+ this.startTimer(scheduled);
57
+ }
58
+
59
+ return scheduled;
60
+ }
61
+
62
+ removeJob(id: string): boolean {
63
+ const timer = this.timers.get(id);
64
+ if (timer) { clearInterval(timer); this.timers.delete(id); }
65
+ return this.jobs.delete(id);
66
+ }
67
+
68
+ enableJob(id: string): boolean {
69
+ const job = this.jobs.get(id);
70
+ if (!job) return false;
71
+ job.enabled = true;
72
+ this.startTimer(job);
73
+ return true;
74
+ }
75
+
76
+ disableJob(id: string): boolean {
77
+ const job = this.jobs.get(id);
78
+ if (!job) return false;
79
+ job.enabled = false;
80
+ const timer = this.timers.get(id);
81
+ if (timer) { clearInterval(timer); this.timers.delete(id); }
82
+ return true;
83
+ }
84
+
85
+ getJob(id: string): ScheduledJob | undefined {
86
+ return this.jobs.get(id);
87
+ }
88
+
89
+ listJobs(): ScheduledJob[] {
90
+ return Array.from(this.jobs.values());
91
+ }
92
+
93
+ private startTimer(job: ScheduledJob): void {
94
+ const existing = this.timers.get(job.id);
95
+ if (existing) clearInterval(existing);
96
+
97
+ const timer = setInterval(() => {
98
+ job.runCount++;
99
+ job.lastRun = new Date().toISOString();
100
+ job.nextRun = new Date(Date.now() + job.intervalMs).toISOString();
101
+ this.emit('job:trigger', job);
102
+ }, job.intervalMs);
103
+
104
+ // Don't keep process alive just for scheduling
105
+ timer.unref();
106
+ this.timers.set(job.id, timer);
107
+ }
108
+
109
+ stopAll(): void {
110
+ for (const [id, timer] of this.timers) {
111
+ clearInterval(timer);
112
+ }
113
+ this.timers.clear();
114
+ }
115
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Task Template Engine — reusable parameterized task definitions.
3
+ *
4
+ * Templates use {{param.name}} syntax for user-supplied values.
5
+ * Templates can be stored, listed, and instantiated.
6
+ *
7
+ * Example:
8
+ * { id: "code-review", prompt: "Review {{param.file}} for {{param.criteria}}", agent: "claude" }
9
+ * Instantiate with: { templateId: "code-review", params: { file: "auth.ts", criteria: "security" } }
10
+ */
11
+
12
+ export interface TaskTemplate {
13
+ id: string;
14
+ name: string;
15
+ description: string;
16
+ prompt: string; // template with {{param.name}} placeholders
17
+ agent?: string; // default agent
18
+ priority?: number;
19
+ params: TemplateParam[]; // parameter definitions
20
+ tags?: string[];
21
+ }
22
+
23
+ export interface TemplateParam {
24
+ name: string;
25
+ description: string;
26
+ required: boolean;
27
+ default?: string;
28
+ }
29
+
30
+ export interface InstantiateRequest {
31
+ templateId: string;
32
+ params: Record<string, string>;
33
+ overrides?: {
34
+ agent?: string;
35
+ priority?: number;
36
+ workingDirectory?: string;
37
+ };
38
+ }
39
+
40
+ export class TemplateEngine {
41
+ private templates = new Map<string, TaskTemplate>();
42
+
43
+ register(template: TaskTemplate): void {
44
+ this.templates.set(template.id, template);
45
+ }
46
+
47
+ unregister(id: string): boolean {
48
+ return this.templates.delete(id);
49
+ }
50
+
51
+ get(id: string): TaskTemplate | undefined {
52
+ return this.templates.get(id);
53
+ }
54
+
55
+ list(tag?: string): TaskTemplate[] {
56
+ const all = Array.from(this.templates.values());
57
+ if (tag) return all.filter(t => t.tags?.includes(tag));
58
+ return all;
59
+ }
60
+
61
+ instantiate(request: InstantiateRequest): { prompt: string; agent?: string; priority?: number } {
62
+ const template = this.templates.get(request.templateId);
63
+ if (!template) throw new Error(`Template not found: ${request.templateId}`);
64
+
65
+ // Validate required params
66
+ for (const param of template.params) {
67
+ if (param.required && !(request.params[param.name] ?? param.default)) {
68
+ throw new Error(`Missing required parameter: ${param.name}`);
69
+ }
70
+ }
71
+
72
+ // Interpolate {{param.name}} with provided values
73
+ let prompt = template.prompt;
74
+ for (const param of template.params) {
75
+ const value = request.params[param.name] ?? param.default ?? '';
76
+ prompt = prompt.replace(new RegExp(`\\{\\{param\\.${param.name}\\}\\}`, 'g'), value);
77
+ }
78
+
79
+ return {
80
+ prompt,
81
+ agent: request.overrides?.agent ?? template.agent,
82
+ priority: request.overrides?.priority ?? template.priority,
83
+ };
84
+ }
85
+
86
+ /** Seed built-in templates */
87
+ seedDefaults(): void {
88
+ this.register({
89
+ id: 'code-review',
90
+ name: 'Code Review',
91
+ description: 'Review a file for quality, security, and correctness',
92
+ prompt: 'Review {{param.file}} for {{param.criteria}}. Focus on actionable issues.',
93
+ agent: 'claude',
94
+ priority: 4,
95
+ params: [
96
+ { name: 'file', description: 'File path to review', required: true },
97
+ { name: 'criteria', description: 'Review criteria', required: false, default: 'quality, security, correctness' },
98
+ ],
99
+ tags: ['review', 'quality'],
100
+ });
101
+
102
+ this.register({
103
+ id: 'implement-feature',
104
+ name: 'Implement Feature',
105
+ description: 'Implement a new feature with tests',
106
+ prompt: 'Implement the following feature: {{param.description}}\n\nRequirements:\n{{param.requirements}}\n\nInclude unit tests.',
107
+ agent: 'codex',
108
+ priority: 3,
109
+ params: [
110
+ { name: 'description', description: 'Feature description', required: true },
111
+ { name: 'requirements', description: 'Detailed requirements', required: false, default: 'Follow existing patterns' },
112
+ ],
113
+ tags: ['implementation', 'feature'],
114
+ });
115
+
116
+ this.register({
117
+ id: 'explain-code',
118
+ name: 'Explain Code',
119
+ description: 'Explain what a piece of code does',
120
+ prompt: 'Explain this code in {{param.detail_level}} detail:\n\n{{param.code}}',
121
+ agent: 'claude',
122
+ priority: 2,
123
+ params: [
124
+ { name: 'code', description: 'Code to explain', required: true },
125
+ { name: 'detail_level', description: 'Detail level (brief/moderate/deep)', required: false, default: 'moderate' },
126
+ ],
127
+ tags: ['explanation', 'documentation'],
128
+ });
129
+
130
+ this.register({
131
+ id: 'debug-issue',
132
+ name: 'Debug Issue',
133
+ description: 'Diagnose and fix a bug',
134
+ prompt: 'Debug this issue: {{param.issue}}\n\nError message: {{param.error}}\n\nRelevant file: {{param.file}}',
135
+ agent: 'claude',
136
+ priority: 5,
137
+ params: [
138
+ { name: 'issue', description: 'Description of the issue', required: true },
139
+ { name: 'error', description: 'Error message or stack trace', required: false, default: 'N/A' },
140
+ { name: 'file', description: 'Relevant file path', required: false, default: 'N/A' },
141
+ ],
142
+ tags: ['debugging', 'fix'],
143
+ });
144
+ }
145
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Webhook Manager — sends HTTP POST notifications on AGW events.
3
+ *
4
+ * Config example:
5
+ * webhooks: [
6
+ * { url: "https://slack.com/...", events: ["task.completed", "combo.completed"], secret: "hmac-key" }
7
+ * ]
8
+ */
9
+
10
+ import { createHmac } from 'node:crypto';
11
+
12
+ export interface WebhookConfig {
13
+ url: string;
14
+ events: string[]; // event types to subscribe to, or ["*"] for all
15
+ secret?: string; // HMAC-SHA256 signing key
16
+ headers?: Record<string, string>;
17
+ retries?: number;
18
+ timeoutMs?: number;
19
+ }
20
+
21
+ export interface WebhookPayload {
22
+ event: string;
23
+ timestamp: string;
24
+ data: Record<string, unknown>;
25
+ }
26
+
27
+ export class WebhookManager {
28
+ private hooks: WebhookConfig[] = [];
29
+
30
+ addWebhook(config: WebhookConfig): void {
31
+ this.hooks.push(config);
32
+ }
33
+
34
+ removeWebhook(url: string): void {
35
+ this.hooks = this.hooks.filter(h => h.url !== url);
36
+ }
37
+
38
+ getWebhooks(): WebhookConfig[] {
39
+ return this.hooks.map(h => ({ ...h, secret: h.secret ? '***' : undefined }));
40
+ }
41
+
42
+ async emit(event: string, data: Record<string, unknown>): Promise<void> {
43
+ const payload: WebhookPayload = {
44
+ event,
45
+ timestamp: new Date().toISOString(),
46
+ data,
47
+ };
48
+ const body = JSON.stringify(payload);
49
+
50
+ const matching = this.hooks.filter(h =>
51
+ h.events.includes('*') || h.events.includes(event)
52
+ );
53
+
54
+ const deliveries = matching.map(hook => this.deliver(hook, body));
55
+ await Promise.allSettled(deliveries);
56
+ }
57
+
58
+ private async deliver(hook: WebhookConfig, body: string): Promise<void> {
59
+ const maxRetries = hook.retries ?? 2;
60
+ const timeout = hook.timeoutMs ?? 10_000;
61
+
62
+ const headers: Record<string, string> = {
63
+ 'Content-Type': 'application/json',
64
+ 'User-Agent': 'AGW-Webhook/1.5',
65
+ ...hook.headers,
66
+ };
67
+
68
+ if (hook.secret) {
69
+ const signature = createHmac('sha256', hook.secret).update(body).digest('hex');
70
+ headers['X-AGW-Signature'] = `sha256=${signature}`;
71
+ }
72
+
73
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
74
+ try {
75
+ const controller = new AbortController();
76
+ const timer = setTimeout(() => controller.abort(), timeout);
77
+
78
+ const res = await fetch(hook.url, {
79
+ method: 'POST',
80
+ headers,
81
+ body,
82
+ signal: controller.signal,
83
+ });
84
+
85
+ clearTimeout(timer);
86
+
87
+ if (res.ok) return;
88
+ if (res.status >= 400 && res.status < 500) return; // Don't retry client errors
89
+ } catch {
90
+ // Retry on network errors
91
+ }
92
+
93
+ if (attempt < maxRetries) {
94
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
95
+ }
96
+ }
97
+ }
98
+ }