aegis-bridge 0.1.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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +404 -0
  3. package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
  4. package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
  5. package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
  6. package/dashboard/dist/index.html +14 -0
  7. package/dist/api-contracts.d.ts +229 -0
  8. package/dist/api-contracts.js +7 -0
  9. package/dist/api-contracts.typecheck.d.ts +14 -0
  10. package/dist/api-contracts.typecheck.js +1 -0
  11. package/dist/api-error-envelope.d.ts +15 -0
  12. package/dist/api-error-envelope.js +80 -0
  13. package/dist/auth.d.ts +87 -0
  14. package/dist/auth.js +276 -0
  15. package/dist/channels/index.d.ts +8 -0
  16. package/dist/channels/index.js +8 -0
  17. package/dist/channels/manager.d.ts +47 -0
  18. package/dist/channels/manager.js +115 -0
  19. package/dist/channels/telegram-style.d.ts +118 -0
  20. package/dist/channels/telegram-style.js +202 -0
  21. package/dist/channels/telegram.d.ts +91 -0
  22. package/dist/channels/telegram.js +1518 -0
  23. package/dist/channels/types.d.ts +77 -0
  24. package/dist/channels/types.js +8 -0
  25. package/dist/channels/webhook.d.ts +60 -0
  26. package/dist/channels/webhook.js +216 -0
  27. package/dist/cli.d.ts +8 -0
  28. package/dist/cli.js +252 -0
  29. package/dist/config.d.ts +90 -0
  30. package/dist/config.js +214 -0
  31. package/dist/consensus.d.ts +16 -0
  32. package/dist/consensus.js +19 -0
  33. package/dist/continuation-pointer.d.ts +11 -0
  34. package/dist/continuation-pointer.js +65 -0
  35. package/dist/diagnostics.d.ts +27 -0
  36. package/dist/diagnostics.js +95 -0
  37. package/dist/error-categories.d.ts +39 -0
  38. package/dist/error-categories.js +73 -0
  39. package/dist/events.d.ts +133 -0
  40. package/dist/events.js +389 -0
  41. package/dist/fault-injection.d.ts +29 -0
  42. package/dist/fault-injection.js +115 -0
  43. package/dist/file-utils.d.ts +2 -0
  44. package/dist/file-utils.js +37 -0
  45. package/dist/handshake.d.ts +60 -0
  46. package/dist/handshake.js +124 -0
  47. package/dist/hook-settings.d.ts +80 -0
  48. package/dist/hook-settings.js +272 -0
  49. package/dist/hook.d.ts +19 -0
  50. package/dist/hook.js +231 -0
  51. package/dist/hooks.d.ts +32 -0
  52. package/dist/hooks.js +364 -0
  53. package/dist/jsonl-watcher.d.ts +59 -0
  54. package/dist/jsonl-watcher.js +166 -0
  55. package/dist/logger.d.ts +35 -0
  56. package/dist/logger.js +65 -0
  57. package/dist/mcp-server.d.ts +123 -0
  58. package/dist/mcp-server.js +869 -0
  59. package/dist/memory-bridge.d.ts +27 -0
  60. package/dist/memory-bridge.js +137 -0
  61. package/dist/memory-routes.d.ts +3 -0
  62. package/dist/memory-routes.js +100 -0
  63. package/dist/metrics.d.ts +126 -0
  64. package/dist/metrics.js +286 -0
  65. package/dist/model-router.d.ts +53 -0
  66. package/dist/model-router.js +150 -0
  67. package/dist/monitor.d.ts +103 -0
  68. package/dist/monitor.js +820 -0
  69. package/dist/path-utils.d.ts +11 -0
  70. package/dist/path-utils.js +21 -0
  71. package/dist/permission-evaluator.d.ts +10 -0
  72. package/dist/permission-evaluator.js +48 -0
  73. package/dist/permission-guard.d.ts +51 -0
  74. package/dist/permission-guard.js +196 -0
  75. package/dist/permission-request-manager.d.ts +12 -0
  76. package/dist/permission-request-manager.js +36 -0
  77. package/dist/permission-routes.d.ts +7 -0
  78. package/dist/permission-routes.js +28 -0
  79. package/dist/pipeline.d.ts +97 -0
  80. package/dist/pipeline.js +291 -0
  81. package/dist/process-utils.d.ts +4 -0
  82. package/dist/process-utils.js +73 -0
  83. package/dist/question-manager.d.ts +54 -0
  84. package/dist/question-manager.js +80 -0
  85. package/dist/retry.d.ts +11 -0
  86. package/dist/retry.js +34 -0
  87. package/dist/safe-json.d.ts +12 -0
  88. package/dist/safe-json.js +22 -0
  89. package/dist/screenshot.d.ts +28 -0
  90. package/dist/screenshot.js +60 -0
  91. package/dist/server.d.ts +10 -0
  92. package/dist/server.js +1973 -0
  93. package/dist/session-cleanup.d.ts +18 -0
  94. package/dist/session-cleanup.js +11 -0
  95. package/dist/session.d.ts +379 -0
  96. package/dist/session.js +1568 -0
  97. package/dist/shutdown-utils.d.ts +5 -0
  98. package/dist/shutdown-utils.js +24 -0
  99. package/dist/signal-cleanup-helper.d.ts +48 -0
  100. package/dist/signal-cleanup-helper.js +117 -0
  101. package/dist/sse-limiter.d.ts +47 -0
  102. package/dist/sse-limiter.js +61 -0
  103. package/dist/sse-writer.d.ts +31 -0
  104. package/dist/sse-writer.js +94 -0
  105. package/dist/ssrf.d.ts +102 -0
  106. package/dist/ssrf.js +267 -0
  107. package/dist/startup.d.ts +6 -0
  108. package/dist/startup.js +162 -0
  109. package/dist/suppress.d.ts +33 -0
  110. package/dist/suppress.js +79 -0
  111. package/dist/swarm-monitor.d.ts +117 -0
  112. package/dist/swarm-monitor.js +300 -0
  113. package/dist/template-store.d.ts +45 -0
  114. package/dist/template-store.js +142 -0
  115. package/dist/terminal-parser.d.ts +16 -0
  116. package/dist/terminal-parser.js +346 -0
  117. package/dist/tmux-capture-cache.d.ts +18 -0
  118. package/dist/tmux-capture-cache.js +34 -0
  119. package/dist/tmux.d.ts +183 -0
  120. package/dist/tmux.js +906 -0
  121. package/dist/tool-registry.d.ts +40 -0
  122. package/dist/tool-registry.js +83 -0
  123. package/dist/transcript.d.ts +63 -0
  124. package/dist/transcript.js +284 -0
  125. package/dist/utils/circular-buffer.d.ts +11 -0
  126. package/dist/utils/circular-buffer.js +37 -0
  127. package/dist/utils/redact-headers.d.ts +13 -0
  128. package/dist/utils/redact-headers.js +54 -0
  129. package/dist/validation.d.ts +406 -0
  130. package/dist/validation.js +415 -0
  131. package/dist/verification.d.ts +2 -0
  132. package/dist/verification.js +72 -0
  133. package/dist/worktree-lookup.d.ts +24 -0
  134. package/dist/worktree-lookup.js +71 -0
  135. package/dist/ws-terminal.d.ts +32 -0
  136. package/dist/ws-terminal.js +348 -0
  137. package/package.json +83 -0
package/dist/hook.js ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hook.ts — Claude Code SessionStart hook for Aegis.
4
+ *
5
+ * Writes session_id → window_id mapping to ~/.aegis/session_map.json.
6
+ * Falls back to ~/.manus/ for backward compatibility.
7
+ * Called by CC's hook system, reads payload from stdin.
8
+ *
9
+ * Install: add to ~/.claude/settings.json:
10
+ * {
11
+ * "hooks": {
12
+ * "SessionStart": [{
13
+ * "hooks": [{ "type": "command", "command": "node /path/to/dist/hook.js", "timeout": 5 }]
14
+ * }]
15
+ * }
16
+ * }
17
+ */
18
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs';
19
+ import { join, dirname, resolve } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { stopSignalsSchema, sessionMapSchema, stopPayloadSchema } from './validation.js';
24
+ import { safeJsonParse, safeJsonParseSchema } from './safe-json.js';
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = dirname(__filename);
27
+ // Use ~/.aegis if it exists, fall back to ~/.manus for backward compat
28
+ const AEGIS_DIR = join(homedir(), '.aegis');
29
+ const MANUS_DIR = join(homedir(), '.manus');
30
+ const BRIDGE_DIR = existsSync(AEGIS_DIR) ? AEGIS_DIR : MANUS_DIR;
31
+ const MAP_FILE = join(BRIDGE_DIR, 'session_map.json');
32
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
33
+ const TMUX_PANE_RE = /^%\d+$/;
34
+ const DEFAULT_POINTER_TTL_MS = 24 * 60 * 60 * 1000;
35
+ function normalizeCommandPath(pathValue, platform = process.platform) {
36
+ return platform === 'win32' ? pathValue.replace(/\//g, '\\') : pathValue.replace(/\\/g, '/');
37
+ }
38
+ function quoteCommandPath(pathValue, platform = process.platform) {
39
+ const normalized = normalizeCommandPath(pathValue, platform);
40
+ return `"${normalized.replace(/"/g, '\\"')}"`;
41
+ }
42
+ /** Build a shell-safe command string that invokes hook.js with an explicit Node executable. */
43
+ export function buildHookCommand(scriptPath, nodeExecutable = process.execPath, platform = process.platform) {
44
+ return `${quoteCommandPath(nodeExecutable, platform)} ${quoteCommandPath(scriptPath, platform)}`;
45
+ }
46
+ function getPointerTtlMs() {
47
+ const raw = process.env.AEGIS_CONTINUATION_POINTER_TTL_MS ?? process.env.MANUS_CONTINUATION_POINTER_TTL_MS;
48
+ const parsed = raw ? Number(raw) : NaN;
49
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_POINTER_TTL_MS;
50
+ }
51
+ /** Handle Stop/StopFailure events.
52
+ * Writes a signal file that the Aegis monitor can detect.
53
+ * Issue #15: StopFailure fires on API errors (rate limit, auth failure).
54
+ */
55
+ function handleStopEvent(sessionId, event, payload) {
56
+ const signalFile = join(BRIDGE_DIR, 'stop_signals.json');
57
+ let signals = {};
58
+ if (existsSync(signalFile)) {
59
+ const parsed = safeJsonParseSchema(readFileSync(signalFile, 'utf-8'), stopSignalsSchema, 'stop_signals.json');
60
+ if (parsed.ok) {
61
+ signals = parsed.data;
62
+ }
63
+ else {
64
+ console.warn(`${parsed.error}; starting fresh`);
65
+ }
66
+ }
67
+ const p = stopPayloadSchema.safeParse(payload);
68
+ const pd = p.success ? p.data : {};
69
+ signals[sessionId] = {
70
+ event,
71
+ timestamp: Date.now(),
72
+ // StopFailure may include error info in the payload
73
+ error: pd.error ?? pd.message ?? null,
74
+ error_details: pd.error_details ?? null,
75
+ last_assistant_message: pd.last_assistant_message ?? null,
76
+ agent_id: pd.agent_id ?? null,
77
+ stop_reason: pd.stop_reason ?? null,
78
+ };
79
+ // Atomic write: write to temp file then rename (prevents partial writes on crash)
80
+ const tmpSignalFile = signalFile + '.tmp';
81
+ writeFileSync(tmpSignalFile, JSON.stringify(signals, null, 2));
82
+ renameSync(tmpSignalFile, signalFile);
83
+ console.error(`Aegis hook: ${event} for session ${sessionId.slice(0, 8)}...`);
84
+ }
85
+ function main() {
86
+ // Check for --install flag
87
+ if (process.argv.includes('--install')) {
88
+ install();
89
+ return;
90
+ }
91
+ // Read payload from stdin
92
+ let payload;
93
+ try {
94
+ const input = readFileSync(0, 'utf-8'); // stdin = fd 0
95
+ const parsed = safeJsonParse(input, 'Hook stdin payload');
96
+ if (!parsed.ok || typeof parsed.data !== 'object' || parsed.data === null || Array.isArray(parsed.data)) {
97
+ process.exit(0);
98
+ }
99
+ payload = parsed.data;
100
+ }
101
+ catch { /* malformed or empty stdin — nothing to do */
102
+ process.exit(0);
103
+ }
104
+ const sessionId = payload.session_id || '';
105
+ const cwd = payload.cwd || '';
106
+ const event = payload.hook_event_name || '';
107
+ if (!sessionId) {
108
+ process.exit(0);
109
+ }
110
+ // Handle Stop and StopFailure events — write signal file for monitor
111
+ if (event === 'Stop' || event === 'StopFailure') {
112
+ handleStopEvent(sessionId, event, payload);
113
+ process.exit(0);
114
+ }
115
+ if (event !== 'SessionStart') {
116
+ process.exit(0);
117
+ }
118
+ if (!UUID_RE.test(sessionId)) {
119
+ console.error(`Invalid session_id: ${sessionId}`);
120
+ process.exit(0);
121
+ }
122
+ // Get tmux window info
123
+ const tmuxPane = process.env.TMUX_PANE;
124
+ if (!tmuxPane) {
125
+ console.error('TMUX_PANE not set');
126
+ process.exit(0);
127
+ }
128
+ if (!TMUX_PANE_RE.test(tmuxPane)) {
129
+ console.error(`Invalid TMUX_PANE: ${tmuxPane}`);
130
+ process.exit(0);
131
+ }
132
+ let tmuxInfo;
133
+ try {
134
+ tmuxInfo = execFileSync('tmux', ['display-message', '-t', tmuxPane, '-p', '#{session_name}:#{window_id}:#{window_name}'], { encoding: 'utf-8' }).trim();
135
+ }
136
+ catch { /* tmux not running or pane not found */
137
+ console.error('Failed to get tmux info');
138
+ process.exit(0);
139
+ }
140
+ const parts = tmuxInfo.split(':');
141
+ if (parts.length < 3) {
142
+ process.exit(0);
143
+ }
144
+ const [sessionName, windowId, windowName] = parts;
145
+ const key = `${sessionName}:${windowId}`;
146
+ // Read-modify-write session_map
147
+ mkdirSync(BRIDGE_DIR, { recursive: true });
148
+ let sessionMap = {};
149
+ if (existsSync(MAP_FILE)) {
150
+ const parsed = safeJsonParseSchema(readFileSync(MAP_FILE, 'utf-8'), sessionMapSchema, 'session_map.json');
151
+ if (parsed.ok) {
152
+ sessionMap = parsed.data;
153
+ }
154
+ else {
155
+ console.warn(`${parsed.error}; starting fresh`);
156
+ }
157
+ }
158
+ const writtenAt = Date.now();
159
+ sessionMap[key] = {
160
+ session_id: sessionId,
161
+ cwd,
162
+ window_name: windowName || '',
163
+ transcript_path: payload.transcript_path || null,
164
+ permission_mode: payload.permission_mode || null,
165
+ agent_id: payload.agent_id || null,
166
+ source: payload.source || null,
167
+ agent_type: payload.agent_type || null,
168
+ model: payload.model || null,
169
+ written_at: writtenAt,
170
+ schema_version: 1,
171
+ expires_at: writtenAt + getPointerTtlMs(),
172
+ };
173
+ // Atomic write: write to temp file then rename (prevents race-condition data loss)
174
+ const tmpMapFile = MAP_FILE + '.tmp';
175
+ writeFileSync(tmpMapFile, JSON.stringify(sessionMap, null, 2));
176
+ renameSync(tmpMapFile, MAP_FILE);
177
+ console.error(`Aegis hook: mapped ${key} -> ${sessionId}`);
178
+ }
179
+ function install() {
180
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
181
+ let settings = {};
182
+ if (existsSync(settingsPath)) {
183
+ const parsed = safeJsonParse(readFileSync(settingsPath, 'utf-8'), settingsPath);
184
+ if (!parsed.ok || typeof parsed.data !== 'object' || parsed.data === null || Array.isArray(parsed.data)) {
185
+ console.error(`Failed to read ${settingsPath}`);
186
+ process.exit(1);
187
+ }
188
+ settings = parsed.data;
189
+ }
190
+ const hookCommand = buildHookCommand(join(__dirname, 'hook.js'));
191
+ const hooks = (settings.hooks || {});
192
+ const sessionStart = (hooks.SessionStart || []);
193
+ // Check if already installed
194
+ const isInstalled = sessionStart.some(entry => entry.hooks?.some(h => h.command?.includes('aegis') || h.command?.includes('manus') || h.command?.includes('hook.js')));
195
+ if (isInstalled) {
196
+ console.log('Aegis hook already installed');
197
+ return;
198
+ }
199
+ sessionStart.push({
200
+ hooks: [{ type: 'command', command: hookCommand, timeout: 5 }]
201
+ });
202
+ hooks.SessionStart = sessionStart;
203
+ // Issue #15: Also register Stop and StopFailure hooks
204
+ const hookEntry = { hooks: [{ type: 'command', command: hookCommand, timeout: 5 }] };
205
+ for (const event of ['Stop', 'StopFailure']) {
206
+ const existing = (hooks[event] || []);
207
+ const alreadyInstalled = existing.some(entry => entry.hooks?.some(h => h.command?.includes('aegis') || h.command?.includes('manus') || h.command?.includes('hook.js')));
208
+ if (!alreadyInstalled) {
209
+ existing.push({ ...hookEntry });
210
+ hooks[event] = existing;
211
+ }
212
+ }
213
+ settings.hooks = hooks;
214
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
215
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
216
+ console.log(`Aegis hook installed in ${settingsPath}`);
217
+ }
218
+ const isDirectExecution = (() => {
219
+ const argv1 = process.argv[1];
220
+ if (!argv1)
221
+ return false;
222
+ try {
223
+ return resolve(argv1) === resolve(__filename);
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ })();
229
+ if (isDirectExecution) {
230
+ main();
231
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * hooks.ts — HTTP hook receiver for Claude Code hook events.
3
+ *
4
+ * Claude Code supports type: "http" hooks that POST JSON events to a URL.
5
+ * This module provides a route handler that receives these events and
6
+ * forwards them to SSE subscribers.
7
+ *
8
+ * Hook URL pattern: POST /v1/hooks/{eventName}?sessionId={id}
9
+ * Session identification: X-Session-Id header or sessionId query param.
10
+ *
11
+ * Decision events (PreToolUse, PermissionRequest) return a response body
12
+ * that CC uses to approve/reject tool calls.
13
+ *
14
+ * Issue #169: Phase 1 — HTTP hooks infrastructure.
15
+ * Issue #169: Phase 3 — Hook-driven status detection.
16
+ */
17
+ import type { FastifyInstance } from 'fastify';
18
+ import type { SessionManager } from './session.js';
19
+ import type { SessionEventBus } from './events.js';
20
+ import type { MetricsCollector } from './metrics.js';
21
+ export interface HookRouteDeps {
22
+ sessions: SessionManager;
23
+ eventBus: SessionEventBus;
24
+ metrics?: MetricsCollector;
25
+ }
26
+ /**
27
+ * Register the hooks endpoint on the Fastify app.
28
+ *
29
+ * This MUST be called before auth middleware is set up, OR the /v1/hooks
30
+ * path must be added to the auth skip list in setupAuth().
31
+ */
32
+ export declare function registerHookRoutes(app: FastifyInstance, deps: HookRouteDeps): void;
package/dist/hooks.js ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * hooks.ts — HTTP hook receiver for Claude Code hook events.
3
+ *
4
+ * Claude Code supports type: "http" hooks that POST JSON events to a URL.
5
+ * This module provides a route handler that receives these events and
6
+ * forwards them to SSE subscribers.
7
+ *
8
+ * Hook URL pattern: POST /v1/hooks/{eventName}?sessionId={id}
9
+ * Session identification: X-Session-Id header or sessionId query param.
10
+ *
11
+ * Decision events (PreToolUse, PermissionRequest) return a response body
12
+ * that CC uses to approve/reject tool calls.
13
+ *
14
+ * Issue #169: Phase 1 — HTTP hooks infrastructure.
15
+ * Issue #169: Phase 3 — Hook-driven status detection.
16
+ */
17
+ import { isValidUUID, hookBodySchema, parseIntSafe } from './validation.js';
18
+ import { evaluatePermissionProfile } from './permission-evaluator.js';
19
+ /** CC hook events that require a decision response. */
20
+ const DECISION_EVENTS = new Set(['PreToolUse', 'PermissionRequest']);
21
+ /** Permission modes that should be auto-approved via hook response. */
22
+ const AUTO_APPROVE_MODES = new Set(['bypassPermissions', 'dontAsk', 'acceptEdits', 'plan', 'auto']);
23
+ /** Default timeout for waiting on client permission decision (ms). */
24
+ const PERMISSION_TIMEOUT_MS = 10_000;
25
+ /** Default timeout for waiting on external answer to AskUserQuestion (ms). */
26
+ const ANSWER_TIMEOUT_MS = parseIntSafe(process.env.ANSWER_TIMEOUT_MS, 30_000);
27
+ /** Valid permission_mode values accepted by Claude Code. */
28
+ const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'bypassPermissions']);
29
+ /** Valid CC hook event names (allow any for extensibility, but these are known). */
30
+ const KNOWN_HOOK_EVENTS = new Set([
31
+ 'Stop',
32
+ 'StopFailure',
33
+ 'PreToolUse',
34
+ 'PostToolUse',
35
+ 'PostToolUseFailure',
36
+ 'Notification',
37
+ 'PermissionRequest',
38
+ 'SessionStart',
39
+ 'SessionEnd',
40
+ 'SubagentStart',
41
+ 'SubagentStop',
42
+ 'TaskCompleted',
43
+ 'TeammateIdle',
44
+ 'PreCompact',
45
+ 'PostCompact',
46
+ 'UserPromptSubmit',
47
+ 'WorktreeCreate',
48
+ 'WorktreeRemove',
49
+ 'Elicitation',
50
+ 'ElicitationResult',
51
+ 'FileChanged',
52
+ 'CwdChanged',
53
+ // Issue #703 Phase 1: additional lifecycle events
54
+ 'PermissionDenied',
55
+ 'TaskCreated',
56
+ 'Setup',
57
+ 'ConfigChange',
58
+ 'InstructionsLoaded',
59
+ ]);
60
+ /** Hook events that are informational (logged + forwarded to SSE, no status change). */
61
+ const INFORMATIONAL_EVENTS = new Set([
62
+ 'Notification',
63
+ 'FileChanged',
64
+ 'CwdChanged',
65
+ // Issue #703 Phase 1: informational lifecycle events
66
+ 'Setup',
67
+ 'ConfigChange',
68
+ 'InstructionsLoaded',
69
+ 'PermissionDenied',
70
+ ]);
71
+ /** Map hook event names to the UIState they imply. */
72
+ function hookToUIState(eventName) {
73
+ switch (eventName) {
74
+ case 'Stop':
75
+ case 'TaskCompleted':
76
+ case 'SessionEnd':
77
+ case 'PostCompact': return 'idle';
78
+ case 'StopFailure':
79
+ case 'PostToolUseFailure': return 'error';
80
+ case 'PreToolUse':
81
+ case 'PostToolUse':
82
+ case 'SubagentStart':
83
+ case 'UserPromptSubmit':
84
+ case 'Elicitation':
85
+ case 'ElicitationResult':
86
+ case 'WorktreeCreate':
87
+ case 'WorktreeRemove': return 'working';
88
+ case 'PreCompact': return 'compacting';
89
+ case 'PermissionRequest': return 'permission_prompt';
90
+ case 'TeammateIdle': return 'idle';
91
+ // Issue #703 Phase 1
92
+ case 'TaskCreated': return 'working';
93
+ default: return null;
94
+ }
95
+ }
96
+ /** Extract question text from AskUserQuestion tool_input. */
97
+ function extractQuestionText(toolInput) {
98
+ if (!toolInput)
99
+ return '';
100
+ const questions = toolInput.questions;
101
+ if (!questions || !Array.isArray(questions) || questions.length === 0)
102
+ return '';
103
+ const first = questions[0];
104
+ return first?.question || '';
105
+ }
106
+ /**
107
+ * Register the hooks endpoint on the Fastify app.
108
+ *
109
+ * This MUST be called before auth middleware is set up, OR the /v1/hooks
110
+ * path must be added to the auth skip list in setupAuth().
111
+ */
112
+ export function registerHookRoutes(app, deps) {
113
+ app.post('/v1/hooks/:eventName', async (req, reply) => {
114
+ const { eventName } = req.params;
115
+ // Issue #349: Validate event name against known list to prevent injection
116
+ if (!KNOWN_HOOK_EVENTS.has(eventName)) {
117
+ return reply.status(400).send({ error: `Unknown hook event: ${eventName}` });
118
+ }
119
+ const sessionId = req.headers['x-session-id'] || req.query.sessionId;
120
+ if (!sessionId) {
121
+ return reply.status(400).send({ error: 'Missing session ID — provide X-Session-Id header or sessionId query param' });
122
+ }
123
+ // Issue #580: Reject non-UUID session IDs before getSession lookup.
124
+ if (!isValidUUID(sessionId)) {
125
+ return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
126
+ }
127
+ const session = deps.sessions.getSession(sessionId);
128
+ if (!session) {
129
+ return reply.status(404).send({ error: `Session ${sessionId} not found` });
130
+ }
131
+ // Issue #629/#1131: Validate hook secret from X-Hook-Secret header (query param fallback)
132
+ const hookSecret = req.headers['x-hook-secret']
133
+ || req.query?.secret;
134
+ if (session.hookSecret && hookSecret !== session.hookSecret) {
135
+ return reply.status(401).send({ error: 'Unauthorized — invalid hook secret' });
136
+ }
137
+ // Issue #665: Validate hook body with Zod instead of unsafe casts
138
+ const parseResult = hookBodySchema.safeParse(req.body);
139
+ if (!parseResult.success) {
140
+ return reply.status(400).send({ error: `Invalid hook body: ${parseResult.error.message}` });
141
+ }
142
+ const hookBody = parseResult.data;
143
+ // Issue #88: Track active subagents
144
+ if (eventName === 'SubagentStart') {
145
+ const agentName = hookBody.agent_name || hookBody.tool_input?.command || 'unknown';
146
+ deps.sessions.addSubagent(sessionId, agentName);
147
+ deps.eventBus.emit(sessionId, {
148
+ event: 'subagent_start',
149
+ sessionId,
150
+ timestamp: new Date().toISOString(),
151
+ data: { agentName },
152
+ });
153
+ }
154
+ else if (eventName === 'SubagentStop') {
155
+ const agentName = hookBody.agent_name || 'unknown';
156
+ deps.sessions.removeSubagent(sessionId, agentName);
157
+ deps.eventBus.emit(sessionId, {
158
+ event: 'subagent_stop',
159
+ sessionId,
160
+ timestamp: new Date().toISOString(),
161
+ data: { agentName },
162
+ });
163
+ }
164
+ // Issue #703 Phase 1: PermissionDenied — emit denied event for dashboard/agents
165
+ if (eventName === 'PermissionDenied') {
166
+ deps.eventBus.emit(sessionId, {
167
+ event: 'permission_denied',
168
+ sessionId,
169
+ timestamp: new Date().toISOString(),
170
+ data: {
171
+ toolName: hookBody.tool_name || '',
172
+ reason: hookBody.reason || '',
173
+ },
174
+ });
175
+ }
176
+ // Issue #89 L26: WorktreeCreate/Remove hooks — informational tracking only
177
+ if (eventName === 'WorktreeCreate' || eventName === 'WorktreeRemove') {
178
+ console.log(`Hooks: ${eventName} for session ${sessionId}`);
179
+ }
180
+ // Informational events — log and forward to SSE (already forwarded below via emitHook)
181
+ if (INFORMATIONAL_EVENTS.has(eventName)) {
182
+ console.log(`Hooks: ${eventName} for session ${sessionId}`);
183
+ }
184
+ // PreCompact/PostCompact — update activity timestamp
185
+ if (eventName === 'PreCompact' || eventName === 'PostCompact') {
186
+ session.lastActivity = Date.now();
187
+ }
188
+ // Forward the validated hook event to SSE subscribers
189
+ deps.eventBus.emitHook(sessionId, eventName, hookBody);
190
+ // Issue #89 L25: Capture model field from hook payload for dashboard display
191
+ if (hookBody.model) {
192
+ deps.sessions.updateSessionModel(sessionId, hookBody.model);
193
+ }
194
+ // Issue #89 L24: Validate permission_mode from PermissionRequest hook
195
+ if (eventName === 'PermissionRequest') {
196
+ const rawMode = hookBody.permission_mode;
197
+ if (rawMode !== undefined && !VALID_PERMISSION_MODES.has(rawMode)) {
198
+ console.warn(`Hooks: invalid permission_mode "${rawMode}" from PermissionRequest, using "default"`);
199
+ hookBody.permission_mode = 'default';
200
+ }
201
+ }
202
+ // Issue #169 Phase 3: Update session status from hook event
203
+ // Issue #87: Extract timestamp from hook payload for latency calculation
204
+ const hookReceivedAt = Date.now();
205
+ const hookEventTimestamp = hookBody.timestamp
206
+ ? new Date(hookBody.timestamp).getTime()
207
+ : undefined;
208
+ // Issue #87: Record hook latency if we have a timestamp from the payload
209
+ if (hookEventTimestamp && deps.metrics) {
210
+ const latency = hookReceivedAt - hookEventTimestamp;
211
+ if (latency >= 0) {
212
+ deps.metrics.recordHookLatency(sessionId, latency);
213
+ }
214
+ }
215
+ const prevStatus = deps.sessions.updateStatusFromHook(sessionId, eventName, hookEventTimestamp);
216
+ const newStatus = hookToUIState(eventName);
217
+ // Emit SSE status event only when the hook implies a state change
218
+ if (newStatus && prevStatus !== newStatus) {
219
+ switch (eventName) {
220
+ case 'Stop': {
221
+ // Issue #812: Check if CC is waiting for user input (text-only last assistant message)
222
+ const waiting = await deps.sessions.detectWaitingForInput(sessionId);
223
+ if (waiting) {
224
+ const session = deps.sessions.getSession(sessionId);
225
+ if (session)
226
+ session.status = 'waiting_for_input';
227
+ deps.eventBus.emitStatus(sessionId, 'waiting_for_input', 'Claude finished, waiting for input (hook: Stop)');
228
+ }
229
+ else {
230
+ deps.eventBus.emitStatus(sessionId, 'idle', 'Claude finished (hook: Stop)');
231
+ }
232
+ break;
233
+ }
234
+ case 'PreToolUse':
235
+ case 'PostToolUse':
236
+ deps.eventBus.emitStatus(sessionId, 'working', 'Claude is working (hook: tool use)');
237
+ break;
238
+ case 'PreCompact':
239
+ deps.eventBus.emitStatus(sessionId, 'compacting', 'Claude is compacting context (hook: PreCompact)');
240
+ break;
241
+ case 'PostCompact':
242
+ deps.eventBus.emitStatus(sessionId, 'idle', 'Compaction complete (hook: PostCompact)');
243
+ break;
244
+ case 'Elicitation':
245
+ deps.eventBus.emitStatus(sessionId, 'working', 'Claude is performing MCP elicitation (hook: Elicitation)');
246
+ break;
247
+ case 'ElicitationResult':
248
+ deps.eventBus.emitStatus(sessionId, 'working', 'Elicitation result received (hook: ElicitationResult)');
249
+ break;
250
+ case 'WorktreeCreate':
251
+ deps.eventBus.emitStatus(sessionId, 'working', `Worktree created: ${hookBody.worktree_path || 'unknown'} (hook: WorktreeCreate)`);
252
+ break;
253
+ case 'WorktreeRemove':
254
+ deps.eventBus.emitStatus(sessionId, 'idle', `Worktree removed: ${hookBody.worktree_path || 'unknown'} (hook: WorktreeRemove)`);
255
+ break;
256
+ case 'PermissionRequest':
257
+ deps.eventBus.emitApproval(sessionId, hookBody.permission_prompt || 'Permission requested (hook)');
258
+ break;
259
+ }
260
+ }
261
+ // Decision events need a response body that CC uses
262
+ // Format: { hookSpecificOutput: { hookEventName, permissionDecision, reason? } }
263
+ if (DECISION_EVENTS.has(eventName)) {
264
+ const toolName = hookBody.tool_name || '';
265
+ const permissionPrompt = hookBody.permission_prompt || '';
266
+ if (eventName === 'PreToolUse') {
267
+ // Issue #336: Intercept AskUserQuestion for headless question answering
268
+ if (toolName === 'AskUserQuestion') {
269
+ const toolInput = hookBody.tool_input;
270
+ const toolUseId = hookBody.tool_use_id || '';
271
+ const questionText = extractQuestionText(toolInput);
272
+ // Emit ask_question SSE event for external clients
273
+ deps.eventBus.emit(sessionId, {
274
+ event: 'status',
275
+ sessionId,
276
+ timestamp: new Date().toISOString(),
277
+ data: { status: 'ask_question', questionId: toolUseId, question: questionText },
278
+ });
279
+ console.log(`Hooks: AskUserQuestion for session ${sessionId} — waiting for answer (timeout: ${ANSWER_TIMEOUT_MS}ms)`);
280
+ const answer = await deps.sessions.waitForAnswer(sessionId, toolUseId, questionText, ANSWER_TIMEOUT_MS);
281
+ if (answer !== null) {
282
+ console.log(`Hooks: AskUserQuestion answered for session ${sessionId}`);
283
+ return reply.status(200).send({
284
+ hookSpecificOutput: {
285
+ hookEventName: 'PreToolUse',
286
+ permissionDecision: 'allow',
287
+ updatedInput: { answer },
288
+ },
289
+ });
290
+ }
291
+ // Timeout: allow without answer (CC shows question to user in terminal)
292
+ console.log(`Hooks: AskUserQuestion timeout for session ${sessionId} — allowing without answer`);
293
+ }
294
+ if (session.permissionProfile) {
295
+ const evaluation = evaluatePermissionProfile(session.permissionProfile, {
296
+ toolName,
297
+ toolInput: hookBody.tool_input,
298
+ });
299
+ if (evaluation.behavior === 'deny') {
300
+ deps.eventBus.emit(sessionId, {
301
+ event: 'permission_denied',
302
+ sessionId,
303
+ timestamp: new Date().toISOString(),
304
+ data: { toolName, reason: evaluation.reason },
305
+ });
306
+ return reply.status(200).send({
307
+ hookSpecificOutput: {
308
+ hookEventName: 'PreToolUse',
309
+ permissionDecision: 'deny',
310
+ reason: evaluation.reason,
311
+ },
312
+ });
313
+ }
314
+ if (evaluation.behavior === 'ask') {
315
+ deps.eventBus.emitApproval(sessionId, `Permission profile requires approval for ${toolName}`);
316
+ const decision = await deps.sessions.waitForPermissionDecision(sessionId, PERMISSION_TIMEOUT_MS, toolName, evaluation.reason);
317
+ return reply.status(200).send({
318
+ hookSpecificOutput: {
319
+ hookEventName: 'PreToolUse',
320
+ permissionDecision: decision,
321
+ },
322
+ });
323
+ }
324
+ }
325
+ // Default: allow without modification
326
+ return reply.status(200).send({
327
+ hookSpecificOutput: {
328
+ hookEventName: 'PreToolUse',
329
+ permissionDecision: 'allow',
330
+ },
331
+ });
332
+ }
333
+ if (eventName === 'PermissionRequest') {
334
+ // Issue #284: Hook-based permission approval.
335
+ // Auto-approve modes respond immediately; others wait for client.
336
+ const permMode = session.permissionMode || 'default';
337
+ if (AUTO_APPROVE_MODES.has(permMode)) {
338
+ console.log(`Hooks: auto-approving PermissionRequest for session ${sessionId} (mode: ${permMode})`);
339
+ return reply.status(200).send({
340
+ hookSpecificOutput: {
341
+ hookEventName: 'PermissionRequest',
342
+ permissionDecision: 'allow',
343
+ },
344
+ });
345
+ }
346
+ // Non-auto-approve: wait for client to approve/reject via API.
347
+ // Store pending permission and block until resolved or timeout.
348
+ console.log(`Hooks: waiting for client permission decision for session ${sessionId}`);
349
+ const decision = await deps.sessions.waitForPermissionDecision(sessionId, PERMISSION_TIMEOUT_MS, toolName, permissionPrompt);
350
+ const decisionLabel = decision === 'allow' ? 'approved' : 'rejected';
351
+ console.log(`Hooks: PermissionRequest for session ${sessionId} — ${decisionLabel} by client`);
352
+ return reply.status(200).send({
353
+ hookSpecificOutput: {
354
+ hookEventName: 'PermissionRequest',
355
+ permissionDecision: decision,
356
+ },
357
+ });
358
+ }
359
+ return reply.status(200).send({ ok: true });
360
+ }
361
+ // Non-decision events: simple acknowledgement
362
+ return reply.status(200).send({ ok: true });
363
+ });
364
+ }