aegis-bridge 2.15.0 → 2.15.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.
@@ -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;
@@ -157,6 +157,7 @@ export interface CreateSessionRequest {
157
157
  workDir: string;
158
158
  name?: string;
159
159
  prompt?: string;
160
+ prd?: string;
160
161
  resumeSessionId?: string;
161
162
  claudeCommand?: string;
162
163
  env?: Record<string, string>;
@@ -182,6 +183,7 @@ export interface SessionSummary {
182
183
  createdAt: number;
183
184
  lastActivity: number;
184
185
  permissionMode: string;
186
+ prd?: string;
185
187
  }
186
188
  export interface OkResponse {
187
189
  ok: boolean;
@@ -203,3 +205,22 @@ export interface SessionsListResponse {
203
205
  };
204
206
  }
205
207
  export type SessionStatusCounts = Record<SessionStatusFilter, number>;
208
+ /** Issue #754: Aggregated session statistics. */
209
+ export interface SessionStats {
210
+ active: number;
211
+ byStatus: Partial<Record<UIState, number>>;
212
+ totalCreated: number;
213
+ totalCompleted: number;
214
+ totalFailed: number;
215
+ }
216
+ /** Issue #754: Bulk-delete request body. */
217
+ export interface BatchDeleteRequest {
218
+ ids?: string[];
219
+ status?: UIState;
220
+ }
221
+ /** Issue #754: Bulk-delete response. */
222
+ export interface BatchDeleteResponse {
223
+ deleted: number;
224
+ notFound: string[];
225
+ errors: string[];
226
+ }
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}`);
@@ -57,6 +57,16 @@ interface SessionLatencyResponse {
57
57
  realtime: SessionLatency | null;
58
58
  aggregated: SessionLatencySummary | null;
59
59
  }
60
+ interface MemoryEntryResponse {
61
+ entry: {
62
+ key: string;
63
+ value: string;
64
+ namespace: string;
65
+ created_at: number;
66
+ updated_at: number;
67
+ expires_at?: number;
68
+ };
69
+ }
60
70
  export declare class AegisClient {
61
71
  private baseUrl;
62
72
  private authToken?;
@@ -103,6 +113,9 @@ export declare class AegisClient {
103
113
  }>;
104
114
  }): Promise<PipelineState>;
105
115
  getSwarm(): Promise<Record<string, unknown>>;
116
+ setMemory(key: string, value: string, ttlSeconds?: number): Promise<MemoryEntryResponse>;
117
+ getMemory(key: string): Promise<MemoryEntryResponse>;
118
+ deleteMemory(key: string): Promise<OkResponse>;
106
119
  }
107
120
  export declare function createMcpServer(aegisPort: number, authToken?: string): McpServer;
108
121
  export declare function startMcpServer(port: number, authToken?: string): Promise<void>;
@@ -175,6 +175,20 @@ export class AegisClient {
175
175
  async getSwarm() {
176
176
  return this.request('/v1/swarm');
177
177
  }
178
+ async setMemory(key, value, ttlSeconds) {
179
+ return this.request('/v1/memory', {
180
+ method: 'POST',
181
+ body: JSON.stringify({ key, value, ttlSeconds }),
182
+ });
183
+ }
184
+ async getMemory(key) {
185
+ return this.request(`/v1/memory/${encodeURIComponent(key)}`);
186
+ }
187
+ async deleteMemory(key) {
188
+ return this.request(`/v1/memory/${encodeURIComponent(key)}`, {
189
+ method: 'DELETE',
190
+ });
191
+ }
178
192
  }
179
193
  function formatToolError(e) {
180
194
  if (e instanceof Error) {
@@ -665,6 +679,59 @@ export function createMcpServer(aegisPort, authToken) {
665
679
  return formatToolError(e);
666
680
  }
667
681
  });
682
+ // ── state_set ──
683
+ server.tool('state_set', 'Set a shared state key/value entry via Aegis memory bridge.', {
684
+ key: z.string().describe('State key in namespace/key format (e.g., pipeline/run-123)'),
685
+ value: z.string().describe('State payload as string'),
686
+ ttlSeconds: z.number().int().positive().max(86400 * 30).optional().describe('Optional TTL in seconds (max 30 days)'),
687
+ }, async ({ key, value, ttlSeconds }) => {
688
+ try {
689
+ const result = await client.setMemory(key, value, ttlSeconds);
690
+ return {
691
+ content: [{
692
+ type: 'text',
693
+ text: JSON.stringify(result, null, 2),
694
+ }],
695
+ };
696
+ }
697
+ catch (e) {
698
+ return formatToolError(e);
699
+ }
700
+ });
701
+ // ── state_get ──
702
+ server.tool('state_get', 'Get a shared state key/value entry via Aegis memory bridge.', {
703
+ key: z.string().describe('State key in namespace/key format (e.g., pipeline/run-123)'),
704
+ }, async ({ key }) => {
705
+ try {
706
+ const result = await client.getMemory(key);
707
+ return {
708
+ content: [{
709
+ type: 'text',
710
+ text: JSON.stringify(result, null, 2),
711
+ }],
712
+ };
713
+ }
714
+ catch (e) {
715
+ return formatToolError(e);
716
+ }
717
+ });
718
+ // ── state_delete ──
719
+ server.tool('state_delete', 'Delete a shared state key/value entry via Aegis memory bridge.', {
720
+ key: z.string().describe('State key in namespace/key format (e.g., pipeline/run-123)'),
721
+ }, async ({ key }) => {
722
+ try {
723
+ const result = await client.deleteMemory(key);
724
+ return {
725
+ content: [{
726
+ type: 'text',
727
+ text: JSON.stringify(result, null, 2),
728
+ }],
729
+ };
730
+ }
731
+ catch (e) {
732
+ return formatToolError(e);
733
+ }
734
+ });
668
735
  // ── MCP Prompts (Issue #443) ────────────────────────────────────────
669
736
  server.prompt('implement_issue', 'Create a session and generate a structured implementation prompt for a GitHub issue.', {
670
737
  issueNumber: z.string().describe('GitHub issue number'),
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * path-utils.ts — path helpers shared across session/tmux logic.
3
+ */
4
+ /**
5
+ * Compute the Claude project hash folder from a workDir path.
6
+ *
7
+ * Examples:
8
+ * - /home/user/project -> -home-user-project
9
+ * - D:\\Users\\me\\project -> -d-Users-me-project
10
+ */
11
+ export declare function computeProjectHash(workDir: string): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * path-utils.ts — path helpers shared across session/tmux logic.
3
+ */
4
+ /**
5
+ * Compute the Claude project hash folder from a workDir path.
6
+ *
7
+ * Examples:
8
+ * - /home/user/project -> -home-user-project
9
+ * - D:\\Users\\me\\project -> -d-Users-me-project
10
+ */
11
+ export function computeProjectHash(workDir) {
12
+ const normalized = workDir.replace(/\\/g, '/').trim();
13
+ const withLowerDrive = normalized.replace(/^[A-Za-z]:/, (m) => `${m[0].toLowerCase()}`);
14
+ const segments = withLowerDrive
15
+ .split('/')
16
+ .filter(Boolean)
17
+ .map((segment) => segment.replace(/:/g, '').replace(/\s+/g, '-'));
18
+ if (segments.length === 0)
19
+ return '-';
20
+ return `-${segments.join('-')}`;
21
+ }
@@ -46,7 +46,16 @@ export type PipelineStageStatus = 'pending' | 'running' | 'completed' | 'failed'
46
46
  export interface PipelineState {
47
47
  id: string;
48
48
  name: string;
49
+ currentStage: 'plan' | 'execute' | 'verify' | 'fix' | 'submit' | 'done';
49
50
  status: 'running' | 'completed' | 'failed';
51
+ retryCount: number;
52
+ maxRetries: number;
53
+ stageHistory: Array<{
54
+ stage: string;
55
+ enteredAt: number;
56
+ exitedAt?: number;
57
+ output?: unknown;
58
+ }>;
50
59
  stages: Array<{
51
60
  name: string;
52
61
  status: PipelineStageStatus;
@@ -62,6 +71,7 @@ export declare class PipelineManager {
62
71
  private sessions;
63
72
  private eventBus?;
64
73
  private static readonly PIPELINE_RETRY_MAX_ATTEMPTS;
74
+ private static readonly PIPELINE_FIX_MAX_RETRIES;
65
75
  private pipelines;
66
76
  private pipelineConfigs;
67
77
  private pollInterval;
@@ -78,6 +88,7 @@ export declare class PipelineManager {
78
88
  private advancePipeline;
79
89
  /** Poll running pipelines and advance stages. */
80
90
  private pollPipelines;
91
+ private transitionPipelineStage;
81
92
  /** Detect circular dependencies. Throws if found. */
82
93
  private detectCycles;
83
94
  /** Clean up. */
package/dist/pipeline.js CHANGED
@@ -11,6 +11,7 @@ export class PipelineManager {
11
11
  sessions;
12
12
  eventBus;
13
13
  static PIPELINE_RETRY_MAX_ATTEMPTS = 3;
14
+ static PIPELINE_FIX_MAX_RETRIES = 3;
14
15
  pipelines = new Map();
15
16
  pipelineConfigs = new Map(); // #219: preserve original stage config
16
17
  pollInterval = null;
@@ -72,7 +73,11 @@ export class PipelineManager {
72
73
  const pipeline = {
73
74
  id,
74
75
  name: config.name,
76
+ currentStage: 'plan',
75
77
  status: 'running',
78
+ retryCount: 0,
79
+ maxRetries: PipelineManager.PIPELINE_FIX_MAX_RETRIES,
80
+ stageHistory: [{ stage: 'plan', enteredAt: Date.now() }],
76
81
  stages: config.stages.map(s => ({
77
82
  name: s.name,
78
83
  status: 'pending',
@@ -108,11 +113,14 @@ export class PipelineManager {
108
113
  // If any stage failed, fail the pipeline
109
114
  if (failedStages.length > 0) {
110
115
  pipeline.status = 'failed';
116
+ this.transitionPipelineStage(pipeline, 'fix', { reason: 'stage_failed', failedStages: failedStages.map(s => s.name) });
111
117
  return;
112
118
  }
113
119
  // Check if all stages are completed
114
120
  if (pipeline.stages.every(s => s.status === 'completed')) {
115
121
  pipeline.status = 'completed';
122
+ this.transitionPipelineStage(pipeline, 'submit', { reason: 'all_stages_completed' });
123
+ this.transitionPipelineStage(pipeline, 'done', { status: 'completed' });
116
124
  if (this.eventBus) {
117
125
  this.eventBus.emitEnded(id, 'pipeline_completed');
118
126
  }
@@ -148,13 +156,23 @@ export class PipelineManager {
148
156
  stage.sessionId = session.id;
149
157
  stage.status = 'running';
150
158
  stage.startedAt = Date.now();
159
+ this.transitionPipelineStage(pipeline, 'execute', { stage: stage.name, sessionId: session.id });
151
160
  }
152
161
  catch (e) {
153
162
  stage.status = 'failed';
154
163
  stage.error = getErrorMessage(e);
155
164
  pipeline.status = 'failed';
165
+ this.transitionPipelineStage(pipeline, 'fix', { stage: stage.name, error: stage.error });
156
166
  }
157
167
  }
168
+ const hasRunning = pipeline.stages.some(s => s.status === 'running');
169
+ const hasPending = pipeline.stages.some(s => s.status === 'pending');
170
+ if (hasRunning) {
171
+ this.transitionPipelineStage(pipeline, 'verify', { runningStages: pipeline.stages.filter(s => s.status === 'running').map(s => s.name) });
172
+ }
173
+ else if (hasPending) {
174
+ this.transitionPipelineStage(pipeline, 'plan', { pendingStages: pipeline.stages.filter(s => s.status === 'pending').map(s => s.name) });
175
+ }
158
176
  }
159
177
  /** Poll running pipelines and advance stages. */
160
178
  async pollPipelines() {
@@ -184,6 +202,7 @@ export class PipelineManager {
184
202
  if (session.status === 'idle') {
185
203
  stage.status = 'completed';
186
204
  stage.completedAt = Date.now();
205
+ this.transitionPipelineStage(pipeline, 'verify', { stageCompleted: stage.name });
187
206
  }
188
207
  }
189
208
  // #219: Use stored original config so stage prompt/permissionMode/autoApprove/workDir are preserved
@@ -207,6 +226,25 @@ export class PipelineManager {
207
226
  }
208
227
  }
209
228
  }
229
+ transitionPipelineStage(pipeline, stage, output) {
230
+ if (pipeline.currentStage === stage)
231
+ return;
232
+ const now = Date.now();
233
+ const previous = pipeline.stageHistory[pipeline.stageHistory.length - 1];
234
+ if (previous && previous.exitedAt === undefined) {
235
+ previous.exitedAt = now;
236
+ if (output !== undefined)
237
+ previous.output = output;
238
+ }
239
+ pipeline.currentStage = stage;
240
+ pipeline.stageHistory.push({ stage, enteredAt: now });
241
+ if (stage === 'fix') {
242
+ pipeline.retryCount += 1;
243
+ if (pipeline.retryCount > pipeline.maxRetries) {
244
+ pipeline.status = 'failed';
245
+ }
246
+ }
247
+ }
210
248
  /** Detect circular dependencies. Throws if found. */
211
249
  detectCycles(stages) {
212
250
  const graph = new Map();
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';
@@ -348,6 +349,7 @@ const createSessionSchema = z.object({
348
349
  workDir: z.string().min(1),
349
350
  name: z.string().max(200).optional(),
350
351
  prompt: z.string().max(100_000).optional(),
352
+ prd: z.string().max(100_000).optional(),
351
353
  resumeSessionId: z.string().uuid().optional(),
352
354
  claudeCommand: z.string().max(10_000).optional(),
353
355
  env: z.record(z.string(), z.string()).optional(),
@@ -581,15 +583,21 @@ app.get('/v1/events', async (req, reply) => {
581
583
  writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', timestamp: new Date().toISOString() })}\n\n`);
582
584
  await reply;
583
585
  });
584
- // List sessions (with pagination and status filter)
586
+ // List sessions (with pagination, status filter, and project filter)
585
587
  app.get('/v1/sessions', async (req) => {
586
588
  const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
587
589
  const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20', 10) || 20));
588
590
  const statusFilter = req.query.status;
591
+ const projectFilter = req.query.project;
589
592
  let all = sessions.listSessions();
590
593
  if (statusFilter) {
591
594
  all = all.filter(s => s.status === statusFilter);
592
595
  }
596
+ // Issue #754: filter by project (workDir prefix/substring match)
597
+ if (projectFilter) {
598
+ const lower = projectFilter.toLowerCase();
599
+ all = all.filter(s => s.workDir.toLowerCase().includes(lower));
600
+ }
593
601
  // Sort by createdAt descending (newest first)
594
602
  all.sort((a, b) => b.createdAt - a.createdAt);
595
603
  const total = all.length;
@@ -601,6 +609,68 @@ app.get('/v1/sessions', async (req) => {
601
609
  pagination: { page, limit, total, totalPages },
602
610
  };
603
611
  });
612
+ // Issue #754: Session statistics endpoint
613
+ app.get('/v1/sessions/stats', async () => {
614
+ const all = sessions.listSessions();
615
+ const byStatus = {};
616
+ for (const s of all) {
617
+ byStatus[s.status] = (byStatus[s.status] ?? 0) + 1;
618
+ }
619
+ const global = metrics.getGlobalMetrics(all.length);
620
+ return {
621
+ active: all.length,
622
+ byStatus,
623
+ totalCreated: global.sessions.total_created,
624
+ totalCompleted: global.sessions.completed,
625
+ totalFailed: global.sessions.failed,
626
+ };
627
+ });
628
+ // Issue #754: Bulk-delete sessions by IDs and/or status
629
+ const batchDeleteSchema = z.object({
630
+ ids: z.array(z.string().uuid()).max(100).optional(),
631
+ status: z.enum([
632
+ 'idle', 'working', 'compacting', 'context_warning', 'waiting_for_input',
633
+ 'permission_prompt', 'plan_mode', 'ask_question', 'bash_approval',
634
+ 'settings', 'error', 'unknown',
635
+ ]).optional(),
636
+ }).refine(d => d.ids !== undefined || d.status !== undefined, {
637
+ message: 'At least one of "ids" or "status" is required',
638
+ });
639
+ app.delete('/v1/sessions/batch', async (req, reply) => {
640
+ const parsed = batchDeleteSchema.safeParse(req.body);
641
+ if (!parsed.success) {
642
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
643
+ }
644
+ const { ids, status } = parsed.data;
645
+ // Collect target session IDs
646
+ const targets = new Set(ids ?? []);
647
+ if (status) {
648
+ for (const s of sessions.listSessions()) {
649
+ if (s.status === status)
650
+ targets.add(s.id);
651
+ }
652
+ }
653
+ let deleted = 0;
654
+ const notFound = [];
655
+ const errors = [];
656
+ for (const id of targets) {
657
+ if (!sessions.getSession(id)) {
658
+ notFound.push(id);
659
+ continue;
660
+ }
661
+ try {
662
+ await sessions.killSession(id);
663
+ eventBus.emitEnded(id, 'killed');
664
+ void channels.sessionEnded(makePayload('session.ended', id, 'killed'));
665
+ cleanupTerminatedSessionState(id, { monitor, metrics, toolRegistry });
666
+ deleted++;
667
+ }
668
+ catch (e) {
669
+ errors.push(`${id}: ${e instanceof Error ? e.message : String(e)}`);
670
+ }
671
+ }
672
+ return reply.status(200).send({ deleted, notFound, errors });
673
+ });
604
674
  // Backwards compat: /sessions (no prefix) returns raw array
605
675
  app.get('/sessions', async () => sessions.listSessions());
606
676
  /** Validate workDir — delegates to validation.ts (Issue #435). */
@@ -611,7 +681,7 @@ async function createSessionHandler(req, reply) {
611
681
  if (!parsed.success) {
612
682
  return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
613
683
  }
614
- const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId, memoryKeys } = parsed.data;
684
+ const { workDir, name, prompt, prd, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId, memoryKeys } = parsed.data;
615
685
  if (!workDir)
616
686
  return reply.status(400).send({ error: 'workDir is required' });
617
687
  // Issue #564: Validate installed Claude Code version
@@ -660,7 +730,7 @@ async function createSessionHandler(req, reply) {
660
730
  }
661
731
  }
662
732
  console.time("POST_CREATE_SESSION");
663
- const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId });
733
+ const session = await sessions.createSession({ workDir: safeWorkDir, name, prd, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId });
664
734
  console.timeEnd("POST_CREATE_SESSION");
665
735
  console.time("POST_CHANNEL_CREATED");
666
736
  // Issue #625: Track session in metrics so sessionsCreated counter is accurate
@@ -1606,6 +1676,8 @@ async function main() {
1606
1676
  }
1607
1677
  // Register HTTP hook receiver (Issue #169, Issue #87: pass metrics for latency tracking)
1608
1678
  registerHookRoutes(app, { sessions, eventBus, metrics });
1679
+ // Issue #743: Register model-routing endpoints
1680
+ registerModelRouterRoutes(app);
1609
1681
  // Initialize pipeline manager (Issue #36)
1610
1682
  pipelines = new PipelineManager(sessions, eventBus);
1611
1683
  // Initialize batch rate limiter (Issue #583)
package/dist/session.d.ts CHANGED
@@ -40,6 +40,7 @@ export interface SessionInfo {
40
40
  parentId?: string;
41
41
  children?: string[];
42
42
  permissionPolicy?: PermissionPolicy;
43
+ prd?: string;
43
44
  }
44
45
  export interface SessionState {
45
46
  sessions: Record<string, SessionInfo>;
@@ -135,6 +136,7 @@ export declare class SessionManager {
135
136
  createSession(opts: {
136
137
  workDir: string;
137
138
  name?: string;
139
+ prd?: string;
138
140
  resumeSessionId?: string;
139
141
  claudeCommand?: string;
140
142
  env?: Record<string, string>;
@@ -304,6 +306,7 @@ export declare class SessionManager {
304
306
  createdAt: number;
305
307
  lastActivity: number;
306
308
  permissionMode: string;
309
+ prd?: string;
307
310
  }>;
308
311
  /** Paginated transcript read — does NOT advance the session's byteOffset. */
309
312
  readTranscript(id: string, page?: number, limit?: number, roleFilter?: 'user' | 'assistant' | 'system'): Promise<{
package/dist/session.js CHANGED
@@ -21,6 +21,7 @@ import { PermissionRequestManager } from './permission-request-manager.js';
21
21
  import { QuestionManager } from './question-manager.js';
22
22
  import { Mutex } from 'async-mutex';
23
23
  import { maybeInjectFault } from './fault-injection.js';
24
+ import { computeProjectHash } from './path-utils.js';
24
25
  /** Convert parsed JSON arrays to Sets for activeSubagents (#668). */
25
26
  function hydrateSessions(raw) {
26
27
  const sessions = {};
@@ -562,6 +563,7 @@ export class SessionManager {
562
563
  settingsPatched,
563
564
  hookSettingsFile,
564
565
  hookSecret,
566
+ prd: opts.prd,
565
567
  };
566
568
  this.state.sessions[id] = session;
567
569
  this.invalidateSessionsListCache();
@@ -1207,6 +1209,7 @@ export class SessionManager {
1207
1209
  createdAt: session.createdAt,
1208
1210
  lastActivity: session.lastActivity,
1209
1211
  permissionMode: session.permissionMode,
1212
+ prd: session.prd,
1210
1213
  };
1211
1214
  }
1212
1215
  /** Paginated transcript read — does NOT advance the session's byteOffset. */
@@ -1400,7 +1403,7 @@ export class SessionManager {
1400
1403
  }
1401
1404
  /** Attempt filesystem-based discovery for a single session poll tick. */
1402
1405
  async maybeDiscoverFromFilesystem(session, workDir) {
1403
- const projectHash = '-' + workDir.replace(/^\//, '').replace(/\//g, '-');
1406
+ const projectHash = computeProjectHash(workDir);
1404
1407
  const projectDir = join(this.config.claudeProjectsDir, projectHash);
1405
1408
  if (!existsSync(projectDir))
1406
1409
  return false;
package/dist/tmux.js CHANGED
@@ -11,6 +11,7 @@ import { existsSync, readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { homedir, tmpdir } from 'node:os';
13
13
  import { randomBytes } from 'node:crypto';
14
+ import { computeProjectHash } from './path-utils.js';
14
15
  /** Shell-escape a string by wrapping in single quotes and escaping embedded single quotes. */
15
16
  function shellEscape(s) {
16
17
  return `'${s.replace(/'/g, "'\\''")}'`;
@@ -360,7 +361,7 @@ export class TmuxManager {
360
361
  */
361
362
  async archiveStaleSessionFiles(workDir) {
362
363
  // Compute the project hash the same way Claude CLI does
363
- const projectHash = '-' + workDir.replace(/^\//, '').replace(/\//g, '-');
364
+ const projectHash = computeProjectHash(workDir);
364
365
  const projectDir = join(homedir(), '.claude', 'projects', projectHash);
365
366
  if (!existsSync(projectDir))
366
367
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.15.0",
3
+ "version": "2.15.2",
4
4
  "type": "module",
5
5
  "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
6
6
  "main": "dist/server.js",