aegis-bridge 2.15.0 → 2.15.1

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.
@@ -138,7 +138,7 @@ export interface GlobalMetrics {
138
138
  channel_delivery_ms: LatencySummaryStat;
139
139
  };
140
140
  }
141
- export type SSEEventType = 'status' | 'message' | 'approval' | 'ended' | 'heartbeat' | 'stall' | 'dead' | 'system' | 'hook' | 'subagent_start' | 'subagent_stop' | 'verification';
141
+ export type SSEEventType = 'status' | 'message' | 'approval' | 'ended' | 'heartbeat' | 'stall' | 'dead' | 'system' | 'hook' | 'subagent_start' | 'subagent_stop' | 'verification' | 'permission_denied';
142
142
  export interface SessionSSEEvent {
143
143
  event: SSEEventType;
144
144
  sessionId: string;
@@ -203,3 +203,22 @@ export interface SessionsListResponse {
203
203
  };
204
204
  }
205
205
  export type SessionStatusCounts = Record<SessionStatusFilter, number>;
206
+ /** Issue #754: Aggregated session statistics. */
207
+ export interface SessionStats {
208
+ active: number;
209
+ byStatus: Partial<Record<UIState, number>>;
210
+ totalCreated: number;
211
+ totalCompleted: number;
212
+ totalFailed: number;
213
+ }
214
+ /** Issue #754: Bulk-delete request body. */
215
+ export interface BatchDeleteRequest {
216
+ ids?: string[];
217
+ status?: UIState;
218
+ }
219
+ /** Issue #754: Bulk-delete response. */
220
+ export interface BatchDeleteResponse {
221
+ deleted: number;
222
+ notFound: string[];
223
+ errors: string[];
224
+ }
package/dist/events.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * The monitor pushes events; the SSE route consumes them.
7
7
  */
8
8
  export interface SessionSSEEvent {
9
- event: 'status' | 'message' | 'system' | 'approval' | 'ended' | 'heartbeat' | 'stall' | 'dead' | 'hook' | 'subagent_start' | 'subagent_stop' | 'verification';
9
+ event: 'status' | 'message' | 'system' | 'approval' | 'ended' | 'heartbeat' | 'stall' | 'dead' | 'hook' | 'subagent_start' | 'subagent_stop' | 'verification' | 'permission_denied';
10
10
  sessionId: string;
11
11
  timestamp: string;
12
12
  data: Record<string, unknown>;
package/dist/hooks.js CHANGED
@@ -49,12 +49,23 @@ const KNOWN_HOOK_EVENTS = new Set([
49
49
  'ElicitationResult',
50
50
  'FileChanged',
51
51
  'CwdChanged',
52
+ // Issue #703 Phase 1: additional lifecycle events
53
+ 'PermissionDenied',
54
+ 'TaskCreated',
55
+ 'Setup',
56
+ 'ConfigChange',
57
+ 'InstructionsLoaded',
52
58
  ]);
53
59
  /** Hook events that are informational (logged + forwarded to SSE, no status change). */
54
60
  const INFORMATIONAL_EVENTS = new Set([
55
61
  'Notification',
56
62
  'FileChanged',
57
63
  'CwdChanged',
64
+ // Issue #703 Phase 1: informational lifecycle events
65
+ 'Setup',
66
+ 'ConfigChange',
67
+ 'InstructionsLoaded',
68
+ 'PermissionDenied',
58
69
  ]);
59
70
  /** Map hook event names to the UIState they imply. */
60
71
  function hookToUIState(eventName) {
@@ -76,6 +87,8 @@ function hookToUIState(eventName) {
76
87
  case 'PreCompact': return 'compacting';
77
88
  case 'PermissionRequest': return 'permission_prompt';
78
89
  case 'TeammateIdle': return 'idle';
90
+ // Issue #703 Phase 1
91
+ case 'TaskCreated': return 'working';
79
92
  default: return null;
80
93
  }
81
94
  }
@@ -146,6 +159,18 @@ export function registerHookRoutes(app, deps) {
146
159
  data: { agentName },
147
160
  });
148
161
  }
162
+ // Issue #703 Phase 1: PermissionDenied — emit denied event for dashboard/agents
163
+ if (eventName === 'PermissionDenied') {
164
+ deps.eventBus.emit(sessionId, {
165
+ event: 'permission_denied',
166
+ sessionId,
167
+ timestamp: new Date().toISOString(),
168
+ data: {
169
+ toolName: hookBody.tool_name || '',
170
+ reason: hookBody.reason || '',
171
+ },
172
+ });
173
+ }
149
174
  // Issue #89 L26: WorktreeCreate/Remove hooks — informational tracking only
150
175
  if (eventName === 'WorktreeCreate' || eventName === 'WorktreeRemove') {
151
176
  console.log(`Hooks: ${eventName} for session ${sessionId}`);
@@ -1,3 +1,4 @@
1
+ import { isValidUUID } from './validation.js';
1
2
  import { z } from 'zod';
2
3
  const setMemorySchema = z.object({
3
4
  key: z.string().max(256),
@@ -50,4 +51,50 @@ export function registerMemoryRoutes(app, bridge) {
50
51
  return reply.status(404).send({ error: `Key not found: ${key}` });
51
52
  return { ok: true };
52
53
  });
54
+ // Issue #705: Scoped memory retrieval — GET /v1/memories?scope=project|user|team
55
+ const VALID_SCOPES = new Set(['project', 'user', 'team']);
56
+ app.get('/v1/memories', async (req, reply) => {
57
+ const { scope } = req.query;
58
+ if (!scope || !VALID_SCOPES.has(scope)) {
59
+ return reply.status(400).send({ error: 'scope must be one of: project, user, team' });
60
+ }
61
+ const entries = bridge.list(`${scope}/`);
62
+ return { scope, entries };
63
+ });
64
+ // Issue #705: Session-linked memories — POST /v1/sessions/:id/memories
65
+ const sessionMemoryWriteSchema = z.object({
66
+ key: z.string().min(1).max(200),
67
+ value: z.string().max(100 * 1024),
68
+ ttlSeconds: z.number().int().positive().max(86400 * 30).optional(),
69
+ }).strict();
70
+ app.post('/v1/sessions/:id/memories', async (req, reply) => {
71
+ const { id } = req.params;
72
+ if (!isValidUUID(id))
73
+ return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
74
+ const parsed = sessionMemoryWriteSchema.safeParse(req.body ?? {});
75
+ if (!parsed.success)
76
+ return reply.status(400).send({ error: 'Invalid body', details: parsed.error.issues });
77
+ const { key, value, ttlSeconds } = parsed.data;
78
+ const fullKey = `session:${id}/${key}`;
79
+ try {
80
+ const entry = bridge.set(fullKey, value, ttlSeconds);
81
+ return { ok: true, entry };
82
+ }
83
+ catch (e) {
84
+ const msg = e instanceof Error ? e.message : String(e);
85
+ if (msg.includes('Invalid key format'))
86
+ return reply.status(400).send({ error: msg });
87
+ if (msg.includes('exceeds maximum size'))
88
+ return reply.status(413).send({ error: msg });
89
+ throw e;
90
+ }
91
+ });
92
+ // Issue #705: Session-linked memories — GET /v1/sessions/:id/memories
93
+ app.get('/v1/sessions/:id/memories', async (req, reply) => {
94
+ const { id } = req.params;
95
+ if (!isValidUUID(id))
96
+ return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
97
+ const entries = bridge.list(`session:${id}/`);
98
+ return { sessionId: id, entries };
99
+ });
53
100
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * model-router.ts — Issue #743: Tiered Model Routing
3
+ *
4
+ * Scores task complexity from metadata (title, labels, description) and routes
5
+ * to the optimal model tier: fast | standard | power.
6
+ *
7
+ * Scoring (0–100):
8
+ * 0–30 → fast (cheapest, e.g. Haiku-class)
9
+ * 31–70 → standard (balanced, e.g. Sonnet-class)
10
+ * 71–100 → power (most capable, e.g. Opus-class)
11
+ *
12
+ * Concrete model names are configurable via environment variables:
13
+ * MODEL_FAST, MODEL_STANDARD, MODEL_POWER
14
+ */
15
+ import type { FastifyInstance } from 'fastify';
16
+ export type ModelTier = 'fast' | 'standard' | 'power';
17
+ export interface RoutingDecision {
18
+ tier: ModelTier;
19
+ model: string;
20
+ score: number;
21
+ reasoning: string[];
22
+ }
23
+ /** Default model names per tier (overridable via env vars). */
24
+ export declare const MODEL_TIERS: Record<ModelTier, string>;
25
+ /**
26
+ * Score a task 0–100 based on its metadata.
27
+ * Returns the score and a human-readable reasoning list.
28
+ */
29
+ export declare function scoreTaskComplexity(title: string, labels: string[], description: string): {
30
+ score: number;
31
+ reasoning: string[];
32
+ };
33
+ /** Map a 0–100 score to a model tier. */
34
+ export declare function scoreToTier(score: number): ModelTier;
35
+ /**
36
+ * Route a task to the optimal model tier and concrete model name.
37
+ *
38
+ * @example
39
+ * routeTask({ title: 'fix typo in README', labels: ['docs'] })
40
+ * // → { tier: 'fast', model: 'claude-haiku-4-5', score: 15, reasoning: [...] }
41
+ */
42
+ export declare function routeTask(opts: {
43
+ title: string;
44
+ labels?: string[];
45
+ description?: string;
46
+ }): RoutingDecision;
47
+ /**
48
+ * Register the model-routing endpoint on the Fastify app.
49
+ *
50
+ * POST /v1/dev/route-task — score a task and return model recommendation.
51
+ * GET /v1/dev/model-tiers — return current model-tier configuration.
52
+ */
53
+ export declare function registerModelRouterRoutes(app: FastifyInstance): void;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * model-router.ts — Issue #743: Tiered Model Routing
3
+ *
4
+ * Scores task complexity from metadata (title, labels, description) and routes
5
+ * to the optimal model tier: fast | standard | power.
6
+ *
7
+ * Scoring (0–100):
8
+ * 0–30 → fast (cheapest, e.g. Haiku-class)
9
+ * 31–70 → standard (balanced, e.g. Sonnet-class)
10
+ * 71–100 → power (most capable, e.g. Opus-class)
11
+ *
12
+ * Concrete model names are configurable via environment variables:
13
+ * MODEL_FAST, MODEL_STANDARD, MODEL_POWER
14
+ */
15
+ import { z } from 'zod';
16
+ /** Keyword signals mapped to model tier. First match in each tier wins. */
17
+ const ROUTING_KEYWORDS = {
18
+ power: [
19
+ 'security', 'auth', 'authentication', 'authorization',
20
+ 'architecture', 'redesign', 'migration', 'critical',
21
+ 'vulnerability', 'injection', 'cryptography', 'encryption',
22
+ 'race condition', 'concurrency', 'breaking change',
23
+ 'permission', 'privilege', 'escalation',
24
+ ],
25
+ standard: [
26
+ 'feature', 'enhancement', 'refactor', 'type-safety',
27
+ 'integration', 'api', 'endpoint', 'validation',
28
+ 'test', 'coverage', 'hook', 'pipeline', 'routing',
29
+ 'module', 'performance', 'optimization',
30
+ ],
31
+ fast: [
32
+ 'typo', 'docs', 'documentation', 'label', 'rename',
33
+ 'bump', 'chore', 'formatting', 'comment', 'readme',
34
+ 'changelog', 'version', 'lint', 'whitespace',
35
+ ],
36
+ };
37
+ /** Default model names per tier (overridable via env vars). */
38
+ export const MODEL_TIERS = {
39
+ fast: process.env.MODEL_FAST ?? 'claude-haiku-4-5',
40
+ standard: process.env.MODEL_STANDARD ?? 'claude-sonnet-4-6',
41
+ power: process.env.MODEL_POWER ?? 'claude-opus-4-6',
42
+ };
43
+ /**
44
+ * Score a task 0–100 based on its metadata.
45
+ * Returns the score and a human-readable reasoning list.
46
+ */
47
+ export function scoreTaskComplexity(title, labels, description) {
48
+ const reasoning = [];
49
+ let score = 35; // baseline: low-standard
50
+ const text = `${title} ${description}`.toLowerCase();
51
+ // Power keywords → raise score to at least power tier threshold
52
+ for (const kw of ROUTING_KEYWORDS.power) {
53
+ if (text.includes(kw)) {
54
+ score = Math.max(score, 75);
55
+ reasoning.push(`power keyword: "${kw}"`);
56
+ break;
57
+ }
58
+ }
59
+ // Fast keywords → lower score to at most fast tier threshold
60
+ for (const kw of ROUTING_KEYWORDS.fast) {
61
+ if (text.includes(kw)) {
62
+ score = Math.min(score, 20);
63
+ reasoning.push(`fast keyword: "${kw}"`);
64
+ break;
65
+ }
66
+ }
67
+ // Standard keywords → minor boost (avoid staying at baseline)
68
+ if (reasoning.length === 0) {
69
+ for (const kw of ROUTING_KEYWORDS.standard) {
70
+ if (text.includes(kw)) {
71
+ score += 5;
72
+ reasoning.push(`standard keyword: "${kw}"`);
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ // Label overrides — applied after keyword signals
78
+ for (const label of labels) {
79
+ const l = label.toLowerCase();
80
+ if (l === 'security' || l === 'critical' || l === 'breaking-change') {
81
+ score = Math.max(score, 80);
82
+ reasoning.push(`label override: "${l}" → power tier`);
83
+ }
84
+ else if (l === 'docs' || l === 'documentation' || l === 'chore') {
85
+ score = Math.min(score, 20);
86
+ reasoning.push(`label override: "${l}" → fast tier`);
87
+ }
88
+ }
89
+ // Priority labels
90
+ for (const label of labels) {
91
+ if (label === 'P0' || label === 'P1') {
92
+ score = Math.max(score, 72);
93
+ reasoning.push(`priority label: "${label}" → elevate to power`);
94
+ }
95
+ else if (label === 'P3') {
96
+ score = Math.min(score, 55);
97
+ reasoning.push(`priority label: "P3" → cap at standard`);
98
+ }
99
+ }
100
+ if (reasoning.length === 0)
101
+ reasoning.push('baseline score — no keyword or label signals');
102
+ return { score: Math.max(0, Math.min(100, score)), reasoning };
103
+ }
104
+ /** Map a 0–100 score to a model tier. */
105
+ export function scoreToTier(score) {
106
+ if (score <= 30)
107
+ return 'fast';
108
+ if (score <= 70)
109
+ return 'standard';
110
+ return 'power';
111
+ }
112
+ /**
113
+ * Route a task to the optimal model tier and concrete model name.
114
+ *
115
+ * @example
116
+ * routeTask({ title: 'fix typo in README', labels: ['docs'] })
117
+ * // → { tier: 'fast', model: 'claude-haiku-4-5', score: 15, reasoning: [...] }
118
+ */
119
+ export function routeTask(opts) {
120
+ const { title, labels = [], description = '' } = opts;
121
+ const { score, reasoning } = scoreTaskComplexity(title, labels, description);
122
+ const tier = scoreToTier(score);
123
+ const model = MODEL_TIERS[tier];
124
+ return { tier, model, score, reasoning };
125
+ }
126
+ /** Zod schema for POST /v1/dev/route-task request body. */
127
+ const routeTaskSchema = z.object({
128
+ title: z.string().min(1).max(500),
129
+ labels: z.array(z.string().max(100)).max(50).optional(),
130
+ description: z.string().max(10_000).optional(),
131
+ });
132
+ /**
133
+ * Register the model-routing endpoint on the Fastify app.
134
+ *
135
+ * POST /v1/dev/route-task — score a task and return model recommendation.
136
+ * GET /v1/dev/model-tiers — return current model-tier configuration.
137
+ */
138
+ export function registerModelRouterRoutes(app) {
139
+ app.post('/v1/dev/route-task', async (req, reply) => {
140
+ const parsed = routeTaskSchema.safeParse(req.body ?? {});
141
+ if (!parsed.success) {
142
+ return reply.status(400).send({ error: 'Invalid body', details: parsed.error.issues });
143
+ }
144
+ const { title, labels, description } = parsed.data;
145
+ return routeTask({ title, labels, description });
146
+ });
147
+ app.get('/v1/dev/model-tiers', async () => {
148
+ return { tiers: MODEL_TIERS };
149
+ });
150
+ }
package/dist/server.js CHANGED
@@ -36,6 +36,7 @@ import { registerPermissionRoutes } from './permission-routes.js';
36
36
  import { registerHookRoutes } from './hooks.js';
37
37
  import { registerWsTerminalRoute } from './ws-terminal.js';
38
38
  import { registerMemoryRoutes } from './memory-routes.js';
39
+ import { registerModelRouterRoutes } from './model-router.js';
39
40
  import * as templateStore from './template-store.js';
40
41
  import { SwarmMonitor } from './swarm-monitor.js';
41
42
  import { killAllSessions } from './signal-cleanup-helper.js';
@@ -581,15 +582,21 @@ app.get('/v1/events', async (req, reply) => {
581
582
  writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', timestamp: new Date().toISOString() })}\n\n`);
582
583
  await reply;
583
584
  });
584
- // List sessions (with pagination and status filter)
585
+ // List sessions (with pagination, status filter, and project filter)
585
586
  app.get('/v1/sessions', async (req) => {
586
587
  const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
587
588
  const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20', 10) || 20));
588
589
  const statusFilter = req.query.status;
590
+ const projectFilter = req.query.project;
589
591
  let all = sessions.listSessions();
590
592
  if (statusFilter) {
591
593
  all = all.filter(s => s.status === statusFilter);
592
594
  }
595
+ // Issue #754: filter by project (workDir prefix/substring match)
596
+ if (projectFilter) {
597
+ const lower = projectFilter.toLowerCase();
598
+ all = all.filter(s => s.workDir.toLowerCase().includes(lower));
599
+ }
593
600
  // Sort by createdAt descending (newest first)
594
601
  all.sort((a, b) => b.createdAt - a.createdAt);
595
602
  const total = all.length;
@@ -601,6 +608,68 @@ app.get('/v1/sessions', async (req) => {
601
608
  pagination: { page, limit, total, totalPages },
602
609
  };
603
610
  });
611
+ // Issue #754: Session statistics endpoint
612
+ app.get('/v1/sessions/stats', async () => {
613
+ const all = sessions.listSessions();
614
+ const byStatus = {};
615
+ for (const s of all) {
616
+ byStatus[s.status] = (byStatus[s.status] ?? 0) + 1;
617
+ }
618
+ const global = metrics.getGlobalMetrics(all.length);
619
+ return {
620
+ active: all.length,
621
+ byStatus,
622
+ totalCreated: global.sessions.total_created,
623
+ totalCompleted: global.sessions.completed,
624
+ totalFailed: global.sessions.failed,
625
+ };
626
+ });
627
+ // Issue #754: Bulk-delete sessions by IDs and/or status
628
+ const batchDeleteSchema = z.object({
629
+ ids: z.array(z.string().uuid()).max(100).optional(),
630
+ status: z.enum([
631
+ 'idle', 'working', 'compacting', 'context_warning', 'waiting_for_input',
632
+ 'permission_prompt', 'plan_mode', 'ask_question', 'bash_approval',
633
+ 'settings', 'error', 'unknown',
634
+ ]).optional(),
635
+ }).refine(d => d.ids !== undefined || d.status !== undefined, {
636
+ message: 'At least one of "ids" or "status" is required',
637
+ });
638
+ app.delete('/v1/sessions/batch', async (req, reply) => {
639
+ const parsed = batchDeleteSchema.safeParse(req.body);
640
+ if (!parsed.success) {
641
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
642
+ }
643
+ const { ids, status } = parsed.data;
644
+ // Collect target session IDs
645
+ const targets = new Set(ids ?? []);
646
+ if (status) {
647
+ for (const s of sessions.listSessions()) {
648
+ if (s.status === status)
649
+ targets.add(s.id);
650
+ }
651
+ }
652
+ let deleted = 0;
653
+ const notFound = [];
654
+ const errors = [];
655
+ for (const id of targets) {
656
+ if (!sessions.getSession(id)) {
657
+ notFound.push(id);
658
+ continue;
659
+ }
660
+ try {
661
+ await sessions.killSession(id);
662
+ eventBus.emitEnded(id, 'killed');
663
+ void channels.sessionEnded(makePayload('session.ended', id, 'killed'));
664
+ cleanupTerminatedSessionState(id, { monitor, metrics, toolRegistry });
665
+ deleted++;
666
+ }
667
+ catch (e) {
668
+ errors.push(`${id}: ${e instanceof Error ? e.message : String(e)}`);
669
+ }
670
+ }
671
+ return reply.status(200).send({ deleted, notFound, errors });
672
+ });
604
673
  // Backwards compat: /sessions (no prefix) returns raw array
605
674
  app.get('/sessions', async () => sessions.listSessions());
606
675
  /** Validate workDir — delegates to validation.ts (Issue #435). */
@@ -1606,6 +1675,8 @@ async function main() {
1606
1675
  }
1607
1676
  // Register HTTP hook receiver (Issue #169, Issue #87: pass metrics for latency tracking)
1608
1677
  registerHookRoutes(app, { sessions, eventBus, metrics });
1678
+ // Issue #743: Register model-routing endpoints
1679
+ registerModelRouterRoutes(app);
1609
1680
  // Initialize pipeline manager (Issue #36)
1610
1681
  pipelines = new PipelineManager(sessions, eventBus);
1611
1682
  // Initialize batch rate limiter (Issue #583)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.15.0",
3
+ "version": "2.15.1",
4
4
  "type": "module",
5
5
  "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
6
6
  "main": "dist/server.js",