aegis-bridge 2.15.1 → 2.15.3

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 CHANGED
@@ -7,6 +7,7 @@
7
7
  <img src="https://img.shields.io/github/actions/workflow/status/OneStepAt4time/aegis/ci.yml?branch=main" alt="CI" />
8
8
  <img src="https://img.shields.io/npm/l/aegis-bridge.svg" alt="license" />
9
9
  <img src="https://img.shields.io/badge/node-%3E%3D20.0.0-blue.svg" alt="node" />
10
+ <img src="https://img.shields.io/badge/MCP-ready-green.svg" alt="MCP ready" />
10
11
  </p>
11
12
 
12
13
  <p align="center">
@@ -96,6 +97,14 @@ Or via `.mcp.json`:
96
97
 
97
98
  **3 prompts** — `implement_issue`, `review_pr`, `debug_session`
98
99
 
100
+ ## Ecosystem Integrations
101
+
102
+ Aegis works beyond Claude Code anywhere an MCP host can launch a local stdio server.
103
+
104
+ - [Cursor integration](docs/integrations/cursor.md)
105
+ - [Windsurf integration](docs/integrations/windsurf.md)
106
+ - [MCP Registry preparation](docs/integrations/mcp-registry.md)
107
+
99
108
  ---
100
109
 
101
110
  ## REST API
@@ -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;
@@ -0,0 +1,16 @@
1
+ export type ConsensusFocusArea = 'correctness' | 'security' | 'performance';
2
+ export interface ConsensusRequest {
3
+ id: string;
4
+ targetSessionId: string;
5
+ reviewerIds: string[];
6
+ focusAreas: ConsensusFocusArea[];
7
+ status: 'running' | 'completed' | 'failed';
8
+ createdAt: number;
9
+ }
10
+ export interface ConsensusReview {
11
+ reviewerId: string;
12
+ focusArea: ConsensusFocusArea;
13
+ findings: string[];
14
+ }
15
+ export declare function buildConsensusPrompt(targetSessionId: string, focusArea: ConsensusFocusArea): string;
16
+ export declare function mergeConsensusFindings(reviews: ConsensusReview[]): string[];
@@ -0,0 +1,19 @@
1
+ export function buildConsensusPrompt(targetSessionId, focusArea) {
2
+ return [
3
+ `Review Aegis session ${targetSessionId}.`,
4
+ `Focus area: ${focusArea}.`,
5
+ 'Return concise findings ordered by severity.',
6
+ 'Prefer concrete regressions, risks, and missing verification.',
7
+ ].join(' ');
8
+ }
9
+ export function mergeConsensusFindings(reviews) {
10
+ const merged = new Set();
11
+ for (const review of reviews) {
12
+ for (const finding of review.findings) {
13
+ const normalized = finding.trim();
14
+ if (normalized)
15
+ merged.add(normalized);
16
+ }
17
+ }
18
+ return Array.from(merged.values());
19
+ }
package/dist/hooks.js CHANGED
@@ -15,6 +15,7 @@
15
15
  * Issue #169: Phase 3 — Hook-driven status detection.
16
16
  */
17
17
  import { isValidUUID, hookBodySchema, parseIntSafe } from './validation.js';
18
+ import { evaluatePermissionProfile } from './permission-evaluator.js';
18
19
  /** CC hook events that require a decision response. */
19
20
  const DECISION_EVENTS = new Set(['PreToolUse', 'PermissionRequest']);
20
21
  /** Permission modes that should be auto-approved via hook response. */
@@ -289,6 +290,37 @@ export function registerHookRoutes(app, deps) {
289
290
  // Timeout: allow without answer (CC shows question to user in terminal)
290
291
  console.log(`Hooks: AskUserQuestion timeout for session ${sessionId} — allowing without answer`);
291
292
  }
293
+ if (session.permissionProfile) {
294
+ const evaluation = evaluatePermissionProfile(session.permissionProfile, {
295
+ toolName,
296
+ toolInput: hookBody.tool_input,
297
+ });
298
+ if (evaluation.behavior === 'deny') {
299
+ deps.eventBus.emit(sessionId, {
300
+ event: 'permission_denied',
301
+ sessionId,
302
+ timestamp: new Date().toISOString(),
303
+ data: { toolName, reason: evaluation.reason },
304
+ });
305
+ return reply.status(200).send({
306
+ hookSpecificOutput: {
307
+ hookEventName: 'PreToolUse',
308
+ permissionDecision: 'deny',
309
+ reason: evaluation.reason,
310
+ },
311
+ });
312
+ }
313
+ if (evaluation.behavior === 'ask') {
314
+ deps.eventBus.emitApproval(sessionId, `Permission profile requires approval for ${toolName}`);
315
+ const decision = await deps.sessions.waitForPermissionDecision(sessionId, PERMISSION_TIMEOUT_MS, toolName, evaluation.reason);
316
+ return reply.status(200).send({
317
+ hookSpecificOutput: {
318
+ hookEventName: 'PreToolUse',
319
+ permissionDecision: decision,
320
+ },
321
+ });
322
+ }
323
+ }
292
324
  // Default: allow without modification
293
325
  return reply.status(200).send({
294
326
  hookSpecificOutput: {
@@ -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'),
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ import type { PermissionProfile } from './validation.js';
2
+ export interface PermissionEvaluationInput {
3
+ toolName: string;
4
+ toolInput?: Record<string, unknown>;
5
+ }
6
+ export interface PermissionEvaluationResult {
7
+ behavior: 'allow' | 'deny' | 'ask';
8
+ reason: string;
9
+ }
10
+ export declare function evaluatePermissionProfile(profile: PermissionProfile, input: PermissionEvaluationInput): PermissionEvaluationResult;
@@ -0,0 +1,48 @@
1
+ function globToRegExp(pattern) {
2
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
3
+ return new RegExp(`^${escaped}$`, 'i');
4
+ }
5
+ function extractCandidatePaths(toolInput) {
6
+ if (!toolInput)
7
+ return [];
8
+ const values = [toolInput.path, toolInput.file_path, toolInput.target, ...(Array.isArray(toolInput.paths) ? toolInput.paths : [])];
9
+ return values.filter((v) => typeof v === 'string');
10
+ }
11
+ function extractContentSize(toolInput) {
12
+ const content = toolInput?.content;
13
+ return typeof content === 'string' ? content.length : null;
14
+ }
15
+ function isLikelyWriteTool(toolName) {
16
+ return /write|edit|delete|rename|move|create/i.test(toolName);
17
+ }
18
+ export function evaluatePermissionProfile(profile, input) {
19
+ for (const rule of profile.rules) {
20
+ if (rule.tool !== input.toolName)
21
+ continue;
22
+ if (rule.pattern) {
23
+ const candidate = typeof input.toolInput?.command === 'string'
24
+ ? input.toolInput.command
25
+ : JSON.stringify(input.toolInput ?? {});
26
+ if (!globToRegExp(rule.pattern).test(candidate))
27
+ continue;
28
+ }
29
+ if (rule.constraints?.readOnly && isLikelyWriteTool(input.toolName)) {
30
+ return { behavior: 'deny', reason: `Denied by readOnly constraint for ${input.toolName}` };
31
+ }
32
+ if (rule.constraints?.paths && rule.constraints.paths.length > 0) {
33
+ const paths = extractCandidatePaths(input.toolInput);
34
+ const allowed = paths.every((candidate) => rule.constraints.paths.some((prefix) => candidate.startsWith(prefix)));
35
+ if (!allowed) {
36
+ return { behavior: 'deny', reason: `Denied by path constraint for ${input.toolName}` };
37
+ }
38
+ }
39
+ if (rule.constraints?.maxFileSize) {
40
+ const size = extractContentSize(input.toolInput);
41
+ if (size !== null && size > rule.constraints.maxFileSize) {
42
+ return { behavior: 'deny', reason: `Denied by maxFileSize constraint for ${input.toolName}` };
43
+ }
44
+ }
45
+ return { behavior: rule.behavior, reason: `Matched rule for ${input.toolName}` };
46
+ }
47
+ return { behavior: profile.defaultBehavior, reason: 'No matching permission rule' };
48
+ }
@@ -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
@@ -37,6 +37,7 @@ import { registerHookRoutes } from './hooks.js';
37
37
  import { registerWsTerminalRoute } from './ws-terminal.js';
38
38
  import { registerMemoryRoutes } from './memory-routes.js';
39
39
  import { registerModelRouterRoutes } from './model-router.js';
40
+ import { buildConsensusPrompt } from './consensus.js';
40
41
  import * as templateStore from './template-store.js';
41
42
  import { SwarmMonitor } from './swarm-monitor.js';
42
43
  import { killAllSessions } from './signal-cleanup-helper.js';
@@ -48,9 +49,10 @@ import { MemoryBridge } from './memory-bridge.js';
48
49
  import { cleanupTerminatedSessionState } from './session-cleanup.js';
49
50
  import { normalizeApiErrorPayload } from './api-error-envelope.js';
50
51
  import { listenWithRetry, removePidFile, writePidFile } from './startup.js';
51
- import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, handshakeRequestSchema, parseIntSafe, isValidUUID, compareSemver, extractCCVersion, MIN_CC_VERSION, } from './validation.js';
52
+ import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, handshakeRequestSchema, parseIntSafe, isValidUUID, compareSemver, extractCCVersion, MIN_CC_VERSION, permissionProfileSchema, } from './validation.js';
52
53
  const __filename = fileURLToPath(import.meta.url);
53
54
  const __dirname = path.dirname(__filename);
55
+ const consensusRequests = new Map();
54
56
  // ── Configuration ────────────────────────────────────────────────────
55
57
  // Issue #349: CSP policy for dashboard responses (shared between static and SPA fallback)
56
58
  const DASHBOARD_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:";
@@ -349,6 +351,7 @@ const createSessionSchema = z.object({
349
351
  workDir: z.string().min(1),
350
352
  name: z.string().max(200).optional(),
351
353
  prompt: z.string().max(100_000).optional(),
354
+ prd: z.string().max(100_000).optional(),
352
355
  resumeSessionId: z.string().uuid().optional(),
353
356
  claudeCommand: z.string().max(10_000).optional(),
354
357
  env: z.record(z.string(), z.string()).optional(),
@@ -680,7 +683,7 @@ async function createSessionHandler(req, reply) {
680
683
  if (!parsed.success) {
681
684
  return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
682
685
  }
683
- const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId, memoryKeys } = parsed.data;
686
+ const { workDir, name, prompt, prd, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId, memoryKeys } = parsed.data;
684
687
  if (!workDir)
685
688
  return reply.status(400).send({ error: 'workDir is required' });
686
689
  // Issue #564: Validate installed Claude Code version
@@ -729,7 +732,7 @@ async function createSessionHandler(req, reply) {
729
732
  }
730
733
  }
731
734
  console.time("POST_CREATE_SESSION");
732
- const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId });
735
+ const session = await sessions.createSession({ workDir: safeWorkDir, name, prd, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId });
733
736
  console.timeEnd("POST_CREATE_SESSION");
734
737
  console.time("POST_CHANNEL_CREATED");
735
738
  // Issue #625: Track session in metrics so sessionsCreated counter is accurate
@@ -903,6 +906,48 @@ async function forkSessionHandler(req, reply) {
903
906
  }
904
907
  app.post('/v1/sessions/:id/fork', forkSessionHandler);
905
908
  app.post('/sessions/:id/fork', forkSessionHandler);
909
+ async function createConsensusHandler(req, reply) {
910
+ const targetSessionId = req.params.id;
911
+ const target = sessions.getSession(targetSessionId);
912
+ if (!target)
913
+ return reply.status(404).send({ error: 'Target session not found' });
914
+ const focusAreas = (req.body?.focusAreas && req.body.focusAreas.length > 0)
915
+ ? req.body.focusAreas
916
+ : ['correctness', 'security', 'performance'];
917
+ const reviewerCount = Math.min(5, Math.max(1, req.body?.reviewerCount ?? focusAreas.length));
918
+ const selectedFocus = focusAreas.slice(0, reviewerCount);
919
+ const reviewerIds = [];
920
+ for (let i = 0; i < selectedFocus.length; i += 1) {
921
+ const focus = selectedFocus[i];
922
+ const child = await sessions.createSession({
923
+ workDir: target.workDir,
924
+ name: `consensus-${focus}-${targetSessionId.slice(0, 6)}`,
925
+ parentId: targetSessionId,
926
+ permissionMode: target.permissionMode,
927
+ });
928
+ reviewerIds.push(child.id);
929
+ await sessions.sendInitialPrompt(child.id, buildConsensusPrompt(targetSessionId, focus));
930
+ }
931
+ const consensusId = crypto.randomUUID();
932
+ const record = {
933
+ id: consensusId,
934
+ targetSessionId,
935
+ reviewerIds,
936
+ focusAreas: selectedFocus,
937
+ status: 'running',
938
+ createdAt: Date.now(),
939
+ };
940
+ consensusRequests.set(consensusId, record);
941
+ return reply.status(202).send(record);
942
+ }
943
+ function getConsensusHandler(req, reply) {
944
+ const item = consensusRequests.get(req.params.id);
945
+ if (!item)
946
+ return reply.status(404).send({ error: 'Consensus request not found' });
947
+ return item;
948
+ }
949
+ app.post('/v1/sessions/:id/consensus', createConsensusHandler);
950
+ app.get('/v1/consensus/:id', getConsensusHandler);
906
951
  async function getPermissionPolicyHandler(req, reply) {
907
952
  const sessionId = req.params.id;
908
953
  const session = sessions.getSession(sessionId);
@@ -927,6 +972,29 @@ app.get('/v1/sessions/:id/permissions', getPermissionPolicyHandler);
927
972
  app.put('/v1/sessions/:id/permissions', updatePermissionPolicyHandler);
928
973
  app.get('/sessions/:id/permissions', getPermissionPolicyHandler);
929
974
  app.put('/sessions/:id/permissions', updatePermissionPolicyHandler);
975
+ async function getPermissionProfileHandler(req, reply) {
976
+ const sessionId = req.params.id;
977
+ const session = sessions.getSession(sessionId);
978
+ if (!session)
979
+ return reply.status(404).send({ error: 'Session not found' });
980
+ return { permissionProfile: session.permissionProfile ?? null };
981
+ }
982
+ async function updatePermissionProfileHandler(req, reply) {
983
+ const sessionId = req.params.id;
984
+ const session = sessions.getSession(sessionId);
985
+ if (!session)
986
+ return reply.status(404).send({ error: 'Session not found' });
987
+ const parsed = permissionProfileSchema.safeParse(req.body ?? {});
988
+ if (!parsed.success)
989
+ return reply.status(400).send({ error: 'Invalid permission profile', details: parsed.error.issues });
990
+ session.permissionProfile = parsed.data;
991
+ await sessions.save();
992
+ return { permissionProfile: parsed.data };
993
+ }
994
+ app.get('/v1/sessions/:id/permission-profile', getPermissionProfileHandler);
995
+ app.put('/v1/sessions/:id/permission-profile', updatePermissionProfileHandler);
996
+ app.get('/sessions/:id/permission-profile', getPermissionProfileHandler);
997
+ app.put('/sessions/:id/permission-profile', updatePermissionProfileHandler);
930
998
  // Read messages
931
999
  async function readMessagesHandler(req, reply) {
932
1000
  try {
package/dist/session.d.ts CHANGED
@@ -8,7 +8,7 @@ import { TmuxManager } from './tmux.js';
8
8
  import { type ParsedEntry } from './transcript.js';
9
9
  import { type UIState } from './terminal-parser.js';
10
10
  import type { Config } from './config.js';
11
- import { type PermissionPolicy } from './validation.js';
11
+ import { type PermissionPolicy, type PermissionProfile } from './validation.js';
12
12
  import { type PermissionDecision } from './permission-request-manager.js';
13
13
  export interface SessionInfo {
14
14
  id: string;
@@ -40,6 +40,8 @@ export interface SessionInfo {
40
40
  parentId?: string;
41
41
  children?: string[];
42
42
  permissionPolicy?: PermissionPolicy;
43
+ permissionProfile?: PermissionProfile;
44
+ prd?: string;
43
45
  }
44
46
  export interface SessionState {
45
47
  sessions: Record<string, SessionInfo>;
@@ -135,6 +137,7 @@ export declare class SessionManager {
135
137
  createSession(opts: {
136
138
  workDir: string;
137
139
  name?: string;
140
+ prd?: string;
138
141
  resumeSessionId?: string;
139
142
  claudeCommand?: string;
140
143
  env?: Record<string, string>;
@@ -304,6 +307,7 @@ export declare class SessionManager {
304
307
  createdAt: number;
305
308
  lastActivity: number;
306
309
  permissionMode: string;
310
+ prd?: string;
307
311
  }>;
308
312
  /** Paginated transcript read — does NOT advance the session's byteOffset. */
309
313
  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;
@@ -141,6 +141,48 @@ export declare const permissionRuleSchema: z.ZodObject<{
141
141
  commandPattern: z.ZodOptional<z.ZodString>;
142
142
  }, z.core.$strip>;
143
143
  export type PermissionPolicy = z.infer<typeof permissionRuleSchema>[];
144
+ /** Issue #742: richer per-session permission profile. */
145
+ export declare const permissionConstraintSchema: z.ZodObject<{
146
+ readOnly: z.ZodOptional<z.ZodBoolean>;
147
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
148
+ maxFileSize: z.ZodOptional<z.ZodNumber>;
149
+ }, z.core.$strict>;
150
+ export declare const permissionProfileRuleSchema: z.ZodObject<{
151
+ tool: z.ZodString;
152
+ behavior: z.ZodEnum<{
153
+ allow: "allow";
154
+ deny: "deny";
155
+ ask: "ask";
156
+ }>;
157
+ pattern: z.ZodOptional<z.ZodString>;
158
+ constraints: z.ZodOptional<z.ZodObject<{
159
+ readOnly: z.ZodOptional<z.ZodBoolean>;
160
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
161
+ maxFileSize: z.ZodOptional<z.ZodNumber>;
162
+ }, z.core.$strict>>;
163
+ }, z.core.$strict>;
164
+ export declare const permissionProfileSchema: z.ZodObject<{
165
+ defaultBehavior: z.ZodEnum<{
166
+ allow: "allow";
167
+ deny: "deny";
168
+ ask: "ask";
169
+ }>;
170
+ rules: z.ZodArray<z.ZodObject<{
171
+ tool: z.ZodString;
172
+ behavior: z.ZodEnum<{
173
+ allow: "allow";
174
+ deny: "deny";
175
+ ask: "ask";
176
+ }>;
177
+ pattern: z.ZodOptional<z.ZodString>;
178
+ constraints: z.ZodOptional<z.ZodObject<{
179
+ readOnly: z.ZodOptional<z.ZodBoolean>;
180
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
181
+ maxFileSize: z.ZodOptional<z.ZodNumber>;
182
+ }, z.core.$strict>>;
183
+ }, z.core.$strict>>;
184
+ }, z.core.$strict>;
185
+ export type PermissionProfile = z.infer<typeof permissionProfileSchema>;
144
186
  /** Schema for persisted SessionState (sessions: { [id]: SessionInfo }). */
145
187
  export declare const persistedStateSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
146
188
  id: z.ZodString;
@@ -206,6 +248,27 @@ export declare const persistedStateSchema: z.ZodRecord<z.ZodString, z.ZodObject<
206
248
  toolName: z.ZodOptional<z.ZodString>;
207
249
  commandPattern: z.ZodOptional<z.ZodString>;
208
250
  }, z.core.$strip>>>;
251
+ permissionProfile: z.ZodOptional<z.ZodObject<{
252
+ defaultBehavior: z.ZodEnum<{
253
+ allow: "allow";
254
+ deny: "deny";
255
+ ask: "ask";
256
+ }>;
257
+ rules: z.ZodArray<z.ZodObject<{
258
+ tool: z.ZodString;
259
+ behavior: z.ZodEnum<{
260
+ allow: "allow";
261
+ deny: "deny";
262
+ ask: "ask";
263
+ }>;
264
+ pattern: z.ZodOptional<z.ZodString>;
265
+ constraints: z.ZodOptional<z.ZodObject<{
266
+ readOnly: z.ZodOptional<z.ZodBoolean>;
267
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
268
+ maxFileSize: z.ZodOptional<z.ZodNumber>;
269
+ }, z.core.$strict>>;
270
+ }, z.core.$strict>>;
271
+ }, z.core.$strict>>;
209
272
  }, z.core.$strip>>;
210
273
  /** Schema for a single continuation pointer entry in session_map.json (Issue #900). */
211
274
  export declare const sessionMapEntrySchema: z.ZodObject<{
@@ -138,6 +138,22 @@ export const permissionRuleSchema = z.object({
138
138
  toolName: z.string().optional(),
139
139
  commandPattern: z.string().optional(),
140
140
  });
141
+ /** Issue #742: richer per-session permission profile. */
142
+ export const permissionConstraintSchema = z.object({
143
+ readOnly: z.boolean().optional(),
144
+ paths: z.array(z.string().min(1)).max(50).optional(),
145
+ maxFileSize: z.number().int().positive().max(10_000_000).optional(),
146
+ }).strict();
147
+ export const permissionProfileRuleSchema = z.object({
148
+ tool: z.string().min(1),
149
+ behavior: z.enum(['allow', 'deny', 'ask']),
150
+ pattern: z.string().optional(),
151
+ constraints: permissionConstraintSchema.optional(),
152
+ }).strict();
153
+ export const permissionProfileSchema = z.object({
154
+ defaultBehavior: z.enum(['allow', 'deny', 'ask']),
155
+ rules: z.array(permissionProfileRuleSchema).max(100),
156
+ }).strict();
141
157
  /** Schema for persisted SessionState (sessions: { [id]: SessionInfo }). */
142
158
  export const persistedStateSchema = z.record(z.string(), z.object({
143
159
  id: z.string(),
@@ -173,6 +189,7 @@ export const persistedStateSchema = z.record(z.string(), z.object({
173
189
  toolName: z.string().optional(),
174
190
  commandPattern: z.string().optional(),
175
191
  })).optional(),
192
+ permissionProfile: permissionProfileSchema.optional(),
176
193
  }));
177
194
  /** Schema for a single continuation pointer entry in session_map.json (Issue #900). */
178
195
  export const sessionMapEntrySchema = z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.15.1",
3
+ "version": "2.15.3",
4
4
  "type": "module",
5
5
  "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
6
6
  "main": "dist/server.js",