aegis-bridge 2.2.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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
  4. package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
  5. package/dashboard/dist/index.html +14 -0
  6. package/dist/auth.d.ts +76 -0
  7. package/dist/auth.js +219 -0
  8. package/dist/channels/index.d.ts +8 -0
  9. package/dist/channels/index.js +9 -0
  10. package/dist/channels/manager.d.ts +39 -0
  11. package/dist/channels/manager.js +101 -0
  12. package/dist/channels/telegram-style.d.ts +118 -0
  13. package/dist/channels/telegram-style.js +203 -0
  14. package/dist/channels/telegram.d.ts +76 -0
  15. package/dist/channels/telegram.js +1396 -0
  16. package/dist/channels/types.d.ts +77 -0
  17. package/dist/channels/types.js +9 -0
  18. package/dist/channels/webhook.d.ts +58 -0
  19. package/dist/channels/webhook.js +162 -0
  20. package/dist/cli.d.ts +8 -0
  21. package/dist/cli.js +223 -0
  22. package/dist/config.d.ts +60 -0
  23. package/dist/config.js +188 -0
  24. package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
  25. package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
  26. package/dist/dashboard/index.html +14 -0
  27. package/dist/events.d.ts +86 -0
  28. package/dist/events.js +258 -0
  29. package/dist/hook-settings.d.ts +67 -0
  30. package/dist/hook-settings.js +138 -0
  31. package/dist/hook.d.ts +18 -0
  32. package/dist/hook.js +199 -0
  33. package/dist/hooks.d.ts +32 -0
  34. package/dist/hooks.js +279 -0
  35. package/dist/jsonl-watcher.d.ts +57 -0
  36. package/dist/jsonl-watcher.js +159 -0
  37. package/dist/mcp-server.d.ts +60 -0
  38. package/dist/mcp-server.js +788 -0
  39. package/dist/metrics.d.ts +104 -0
  40. package/dist/metrics.js +226 -0
  41. package/dist/monitor.d.ts +84 -0
  42. package/dist/monitor.js +553 -0
  43. package/dist/permission-guard.d.ts +51 -0
  44. package/dist/permission-guard.js +197 -0
  45. package/dist/pipeline.d.ts +84 -0
  46. package/dist/pipeline.js +218 -0
  47. package/dist/screenshot.d.ts +26 -0
  48. package/dist/screenshot.js +57 -0
  49. package/dist/server.d.ts +10 -0
  50. package/dist/server.js +1577 -0
  51. package/dist/session.d.ts +297 -0
  52. package/dist/session.js +1275 -0
  53. package/dist/sse-limiter.d.ts +47 -0
  54. package/dist/sse-limiter.js +62 -0
  55. package/dist/sse-writer.d.ts +31 -0
  56. package/dist/sse-writer.js +95 -0
  57. package/dist/ssrf.d.ts +57 -0
  58. package/dist/ssrf.js +169 -0
  59. package/dist/swarm-monitor.d.ts +114 -0
  60. package/dist/swarm-monitor.js +267 -0
  61. package/dist/terminal-parser.d.ts +16 -0
  62. package/dist/terminal-parser.js +343 -0
  63. package/dist/tmux.d.ts +161 -0
  64. package/dist/tmux.js +725 -0
  65. package/dist/transcript.d.ts +47 -0
  66. package/dist/transcript.js +244 -0
  67. package/dist/validation.d.ts +222 -0
  68. package/dist/validation.js +268 -0
  69. package/dist/ws-terminal.d.ts +32 -0
  70. package/dist/ws-terminal.js +297 -0
  71. package/package.json +71 -0
package/dist/hook.js ADDED
@@ -0,0 +1,199 @@
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 } 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 } from './validation.js';
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+ // Use ~/.aegis if it exists, fall back to ~/.manus for backward compat
27
+ const AEGIS_DIR = join(homedir(), '.aegis');
28
+ const MANUS_DIR = join(homedir(), '.manus');
29
+ const BRIDGE_DIR = existsSync(AEGIS_DIR) ? AEGIS_DIR : MANUS_DIR;
30
+ const MAP_FILE = join(BRIDGE_DIR, 'session_map.json');
31
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
32
+ const TMUX_PANE_RE = /^%\d+$/;
33
+ /** Handle Stop/StopFailure events.
34
+ * Writes a signal file that the Aegis monitor can detect.
35
+ * Issue #15: StopFailure fires on API errors (rate limit, auth failure).
36
+ */
37
+ function handleStopEvent(sessionId, event, payload) {
38
+ const signalFile = join(BRIDGE_DIR, 'stop_signals.json');
39
+ let signals = {};
40
+ if (existsSync(signalFile)) {
41
+ try {
42
+ const parsed = stopSignalsSchema.safeParse(JSON.parse(readFileSync(signalFile, 'utf-8')));
43
+ if (parsed.success) {
44
+ signals = parsed.data;
45
+ }
46
+ else {
47
+ console.warn('stop_signals.json failed validation, starting fresh');
48
+ }
49
+ }
50
+ catch { /* fresh */ }
51
+ }
52
+ signals[sessionId] = {
53
+ event,
54
+ timestamp: Date.now(),
55
+ // StopFailure may include error info in the payload
56
+ error: payload.error || payload.message || null,
57
+ error_details: payload.error_details ?? null,
58
+ last_assistant_message: payload.last_assistant_message ?? null,
59
+ agent_id: payload.agent_id ?? null,
60
+ stop_reason: payload.stop_reason ?? null,
61
+ };
62
+ // Atomic write: write to temp file then rename (prevents partial writes on crash)
63
+ const tmpSignalFile = signalFile + '.tmp';
64
+ writeFileSync(tmpSignalFile, JSON.stringify(signals, null, 2));
65
+ renameSync(tmpSignalFile, signalFile);
66
+ console.error(`Aegis hook: ${event} for session ${sessionId.slice(0, 8)}...`);
67
+ }
68
+ function main() {
69
+ // Check for --install flag
70
+ if (process.argv.includes('--install')) {
71
+ install();
72
+ return;
73
+ }
74
+ // Read payload from stdin
75
+ let payload;
76
+ try {
77
+ const input = readFileSync(0, 'utf-8'); // stdin = fd 0
78
+ payload = JSON.parse(input);
79
+ }
80
+ catch { /* malformed or empty stdin — nothing to do */
81
+ process.exit(0);
82
+ }
83
+ const sessionId = payload.session_id || '';
84
+ const cwd = payload.cwd || '';
85
+ const event = payload.hook_event_name || '';
86
+ if (!sessionId) {
87
+ process.exit(0);
88
+ }
89
+ // Handle Stop and StopFailure events — write signal file for monitor
90
+ if (event === 'Stop' || event === 'StopFailure') {
91
+ handleStopEvent(sessionId, event, payload);
92
+ process.exit(0);
93
+ }
94
+ if (event !== 'SessionStart') {
95
+ process.exit(0);
96
+ }
97
+ if (!UUID_RE.test(sessionId)) {
98
+ console.error(`Invalid session_id: ${sessionId}`);
99
+ process.exit(0);
100
+ }
101
+ // Get tmux window info
102
+ const tmuxPane = process.env.TMUX_PANE;
103
+ if (!tmuxPane) {
104
+ console.error('TMUX_PANE not set');
105
+ process.exit(0);
106
+ }
107
+ if (!TMUX_PANE_RE.test(tmuxPane)) {
108
+ console.error(`Invalid TMUX_PANE: ${tmuxPane}`);
109
+ process.exit(0);
110
+ }
111
+ let tmuxInfo;
112
+ try {
113
+ tmuxInfo = execFileSync('tmux', ['display-message', '-t', tmuxPane, '-p', '#{session_name}:#{window_id}:#{window_name}'], { encoding: 'utf-8' }).trim();
114
+ }
115
+ catch { /* tmux not running or pane not found */
116
+ console.error('Failed to get tmux info');
117
+ process.exit(0);
118
+ }
119
+ const parts = tmuxInfo.split(':');
120
+ if (parts.length < 3) {
121
+ process.exit(0);
122
+ }
123
+ const [sessionName, windowId, windowName] = parts;
124
+ const key = `${sessionName}:${windowId}`;
125
+ // Read-modify-write session_map
126
+ mkdirSync(BRIDGE_DIR, { recursive: true });
127
+ let sessionMap = {};
128
+ if (existsSync(MAP_FILE)) {
129
+ try {
130
+ const parsed = sessionMapSchema.safeParse(JSON.parse(readFileSync(MAP_FILE, 'utf-8')));
131
+ if (parsed.success) {
132
+ sessionMap = parsed.data;
133
+ }
134
+ else {
135
+ console.warn('session_map.json failed validation, starting fresh');
136
+ }
137
+ }
138
+ catch { /* fresh map */ }
139
+ }
140
+ sessionMap[key] = {
141
+ session_id: sessionId,
142
+ cwd,
143
+ window_name: windowName || '',
144
+ transcript_path: payload.transcript_path || null,
145
+ permission_mode: payload.permission_mode || null,
146
+ agent_id: payload.agent_id || null,
147
+ source: payload.source || null,
148
+ agent_type: payload.agent_type || null,
149
+ model: payload.model || null,
150
+ written_at: Date.now(),
151
+ };
152
+ // Atomic write: write to temp file then rename (prevents race-condition data loss)
153
+ const tmpMapFile = MAP_FILE + '.tmp';
154
+ writeFileSync(tmpMapFile, JSON.stringify(sessionMap, null, 2));
155
+ renameSync(tmpMapFile, MAP_FILE);
156
+ console.error(`Aegis hook: mapped ${key} -> ${sessionId}`);
157
+ }
158
+ function install() {
159
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
160
+ let settings = {};
161
+ if (existsSync(settingsPath)) {
162
+ try {
163
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
164
+ }
165
+ catch { /* corrupted or unreadable settings file */
166
+ console.error(`Failed to read ${settingsPath}`);
167
+ process.exit(1);
168
+ }
169
+ }
170
+ const hookCommand = `node ${join(__dirname, 'hook.js')}`;
171
+ const hooks = (settings.hooks || {});
172
+ const sessionStart = (hooks.SessionStart || []);
173
+ // Check if already installed
174
+ const isInstalled = sessionStart.some(entry => entry.hooks?.some(h => h.command?.includes('aegis') || h.command?.includes('manus') || h.command?.includes('hook.js')));
175
+ if (isInstalled) {
176
+ console.log('Aegis hook already installed');
177
+ return;
178
+ }
179
+ sessionStart.push({
180
+ hooks: [{ type: 'command', command: hookCommand, timeout: 5 }]
181
+ });
182
+ hooks.SessionStart = sessionStart;
183
+ // Issue #15: Also register Stop and StopFailure hooks
184
+ const hookEntry = { hooks: [{ type: 'command', command: hookCommand, timeout: 5 }] };
185
+ for (const event of ['Stop', 'StopFailure']) {
186
+ const existing = (hooks[event] || []);
187
+ const alreadyInstalled = existing.some(entry => entry.hooks?.some(h => h.command?.includes('aegis') || h.command?.includes('manus') || h.command?.includes('hook.js')));
188
+ if (!alreadyInstalled) {
189
+ existing.push({ ...hookEntry });
190
+ hooks[event] = existing;
191
+ }
192
+ }
193
+ settings.hooks = hooks;
194
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
195
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
196
+ console.log(`Aegis hook installed in ${settingsPath}`);
197
+ }
198
+ main();
199
+ //# sourceMappingURL=hook.js.map
@@ -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,279 @@
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
+ /** CC hook events that require a decision response. */
18
+ const DECISION_EVENTS = new Set(['PreToolUse', 'PermissionRequest']);
19
+ /** Permission modes that should be auto-approved via hook response. */
20
+ const AUTO_APPROVE_MODES = new Set(['bypassPermissions', 'dontAsk', 'acceptEdits', 'plan', 'auto']);
21
+ /** Default timeout for waiting on client permission decision (ms). */
22
+ const PERMISSION_TIMEOUT_MS = 10_000;
23
+ /** Default timeout for waiting on external answer to AskUserQuestion (ms). */
24
+ const ANSWER_TIMEOUT_MS = parseInt(process.env.ANSWER_TIMEOUT_MS || '30000', 10);
25
+ /** Valid permission_mode values accepted by Claude Code. */
26
+ const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'bypassPermissions']);
27
+ /** Valid CC hook event names (allow any for extensibility, but these are known). */
28
+ const KNOWN_HOOK_EVENTS = new Set([
29
+ 'Stop',
30
+ 'StopFailure',
31
+ 'PreToolUse',
32
+ 'PostToolUse',
33
+ 'PostToolUseFailure',
34
+ 'Notification',
35
+ 'PermissionRequest',
36
+ 'SessionStart',
37
+ 'SessionEnd',
38
+ 'SubagentStart',
39
+ 'SubagentStop',
40
+ 'TaskCompleted',
41
+ 'TeammateIdle',
42
+ 'PreCompact',
43
+ 'PostCompact',
44
+ 'UserPromptSubmit',
45
+ 'WorktreeCreate',
46
+ 'WorktreeCreateFailed',
47
+ 'WorktreeRemove',
48
+ 'WorktreeRemoveFailed',
49
+ 'Elicitation',
50
+ 'ElicitationResult',
51
+ 'FileChanged',
52
+ 'CwdChanged',
53
+ ]);
54
+ /** Hook events that are informational (logged + forwarded to SSE, no status change). */
55
+ const INFORMATIONAL_EVENTS = new Set([
56
+ 'Notification',
57
+ 'FileChanged',
58
+ 'CwdChanged',
59
+ ]);
60
+ /** Map hook event names to the UIState they imply. */
61
+ function hookToUIState(eventName) {
62
+ switch (eventName) {
63
+ case 'Stop':
64
+ case 'TaskCompleted':
65
+ case 'SessionEnd':
66
+ case 'PostCompact': return 'idle';
67
+ case 'StopFailure':
68
+ case 'PostToolUseFailure': return 'error';
69
+ case 'PreToolUse':
70
+ case 'PostToolUse':
71
+ case 'SubagentStart':
72
+ case 'UserPromptSubmit':
73
+ case 'Elicitation':
74
+ case 'ElicitationResult': return 'working';
75
+ case 'PreCompact': return 'compacting';
76
+ case 'PermissionRequest': return 'permission_prompt';
77
+ case 'TeammateIdle': return 'idle';
78
+ default: return null;
79
+ }
80
+ }
81
+ /** Extract question text from AskUserQuestion tool_input. */
82
+ function extractQuestionText(toolInput) {
83
+ if (!toolInput)
84
+ return '';
85
+ const questions = toolInput.questions;
86
+ if (!questions || !Array.isArray(questions) || questions.length === 0)
87
+ return '';
88
+ const first = questions[0];
89
+ return first?.question || '';
90
+ }
91
+ /**
92
+ * Register the hooks endpoint on the Fastify app.
93
+ *
94
+ * This MUST be called before auth middleware is set up, OR the /v1/hooks
95
+ * path must be added to the auth skip list in setupAuth().
96
+ */
97
+ export function registerHookRoutes(app, deps) {
98
+ app.post('/v1/hooks/:eventName', async (req, reply) => {
99
+ const { eventName } = req.params;
100
+ // Issue #349: Validate event name against known list to prevent injection
101
+ if (!KNOWN_HOOK_EVENTS.has(eventName)) {
102
+ return reply.status(400).send({ error: `Unknown hook event: ${eventName}` });
103
+ }
104
+ const sessionId = req.headers['x-session-id'] || req.query.sessionId;
105
+ if (!sessionId) {
106
+ return reply.status(400).send({ error: 'Missing session ID — provide X-Session-Id header or sessionId query param' });
107
+ }
108
+ const session = deps.sessions.getSession(sessionId);
109
+ if (!session) {
110
+ return reply.status(404).send({ error: `Session ${sessionId} not found` });
111
+ }
112
+ // Issue #88: Track active subagents
113
+ const hookBody = req.body;
114
+ if (eventName === 'SubagentStart') {
115
+ const agentName = hookBody?.agent_name || hookBody?.tool_input?.command || 'unknown';
116
+ deps.sessions.addSubagent(sessionId, agentName);
117
+ deps.eventBus.emit(sessionId, {
118
+ event: 'subagent_start',
119
+ sessionId,
120
+ timestamp: new Date().toISOString(),
121
+ data: { agentName },
122
+ });
123
+ }
124
+ else if (eventName === 'SubagentStop') {
125
+ const agentName = hookBody?.agent_name || 'unknown';
126
+ deps.sessions.removeSubagent(sessionId, agentName);
127
+ deps.eventBus.emit(sessionId, {
128
+ event: 'subagent_stop',
129
+ sessionId,
130
+ timestamp: new Date().toISOString(),
131
+ data: { agentName },
132
+ });
133
+ }
134
+ // Issue #89 L26: WorktreeCreate/Remove hooks — informational tracking only
135
+ if (eventName === 'WorktreeCreate' || eventName === 'WorktreeCreateFailed' ||
136
+ eventName === 'WorktreeRemove' || eventName === 'WorktreeRemoveFailed') {
137
+ console.log(`Hooks: ${eventName} for session ${sessionId}`);
138
+ }
139
+ // Informational events — log and forward to SSE (already forwarded below via emitHook)
140
+ if (INFORMATIONAL_EVENTS.has(eventName)) {
141
+ console.log(`Hooks: ${eventName} for session ${sessionId}`);
142
+ }
143
+ // PreCompact/PostCompact — update activity timestamp
144
+ if (eventName === 'PreCompact' || eventName === 'PostCompact') {
145
+ session.lastActivity = Date.now();
146
+ }
147
+ // Forward the raw hook event to SSE subscribers
148
+ deps.eventBus.emitHook(sessionId, eventName, req.body);
149
+ // Issue #89 L25: Capture model field from hook payload for dashboard display
150
+ const hookPayload = req.body;
151
+ if (hookPayload?.model && typeof hookPayload.model === 'string') {
152
+ deps.sessions.updateSessionModel(sessionId, hookPayload.model);
153
+ }
154
+ // Issue #89 L24: Validate permission_mode from PermissionRequest hook
155
+ if (eventName === 'PermissionRequest') {
156
+ const rawMode = hookBody?.permission_mode;
157
+ if (rawMode !== undefined && !VALID_PERMISSION_MODES.has(rawMode)) {
158
+ console.warn(`Hooks: invalid permission_mode "${rawMode}" from PermissionRequest, using "default"`);
159
+ hookBody.permission_mode = 'default';
160
+ }
161
+ }
162
+ // Issue #169 Phase 3: Update session status from hook event
163
+ // Issue #87: Extract timestamp from hook payload for latency calculation
164
+ const hookReceivedAt = Date.now();
165
+ const hookEventTimestamp = hookPayload?.timestamp
166
+ ? new Date(hookPayload.timestamp).getTime()
167
+ : undefined;
168
+ // Issue #87: Record hook latency if we have a timestamp from the payload
169
+ if (hookEventTimestamp && deps.metrics) {
170
+ const latency = hookReceivedAt - hookEventTimestamp;
171
+ if (latency >= 0) {
172
+ deps.metrics.recordHookLatency(sessionId, latency);
173
+ }
174
+ }
175
+ const prevStatus = deps.sessions.updateStatusFromHook(sessionId, eventName, hookEventTimestamp);
176
+ const newStatus = hookToUIState(eventName);
177
+ // Emit SSE status event only when the hook implies a state change
178
+ if (newStatus && prevStatus !== newStatus) {
179
+ switch (eventName) {
180
+ case 'Stop':
181
+ deps.eventBus.emitStatus(sessionId, 'idle', 'Claude finished (hook: Stop)');
182
+ break;
183
+ case 'PreToolUse':
184
+ case 'PostToolUse':
185
+ deps.eventBus.emitStatus(sessionId, 'working', 'Claude is working (hook: tool use)');
186
+ break;
187
+ case 'PreCompact':
188
+ deps.eventBus.emitStatus(sessionId, 'compacting', 'Claude is compacting context (hook: PreCompact)');
189
+ break;
190
+ case 'PostCompact':
191
+ deps.eventBus.emitStatus(sessionId, 'idle', 'Compaction complete (hook: PostCompact)');
192
+ break;
193
+ case 'Elicitation':
194
+ deps.eventBus.emitStatus(sessionId, 'working', 'Claude is performing MCP elicitation (hook: Elicitation)');
195
+ break;
196
+ case 'ElicitationResult':
197
+ deps.eventBus.emitStatus(sessionId, 'working', 'Elicitation result received (hook: ElicitationResult)');
198
+ break;
199
+ case 'PermissionRequest':
200
+ deps.eventBus.emitApproval(sessionId, req.body?.permission_prompt
201
+ || 'Permission requested (hook)');
202
+ break;
203
+ }
204
+ }
205
+ // Decision events need a response body that CC uses
206
+ // Format: { hookSpecificOutput: { hookEventName, permissionDecision, reason? } }
207
+ if (DECISION_EVENTS.has(eventName)) {
208
+ const hookBody = req.body;
209
+ const toolName = hookBody?.tool_name || '';
210
+ const permissionPrompt = hookBody?.permission_prompt || '';
211
+ if (eventName === 'PreToolUse') {
212
+ // Issue #336: Intercept AskUserQuestion for headless question answering
213
+ if (toolName === 'AskUserQuestion') {
214
+ const toolInput = hookBody?.tool_input;
215
+ const toolUseId = hookBody?.tool_use_id || '';
216
+ const questionText = extractQuestionText(toolInput);
217
+ // Emit ask_question SSE event for external clients
218
+ deps.eventBus.emit(sessionId, {
219
+ event: 'status',
220
+ sessionId,
221
+ timestamp: new Date().toISOString(),
222
+ data: { status: 'ask_question', questionId: toolUseId, question: questionText },
223
+ });
224
+ console.log(`Hooks: AskUserQuestion for session ${sessionId} — waiting for answer (timeout: ${ANSWER_TIMEOUT_MS}ms)`);
225
+ const answer = await deps.sessions.waitForAnswer(sessionId, toolUseId, questionText, ANSWER_TIMEOUT_MS);
226
+ if (answer !== null) {
227
+ console.log(`Hooks: AskUserQuestion answered for session ${sessionId}`);
228
+ return reply.status(200).send({
229
+ hookSpecificOutput: {
230
+ hookEventName: 'PreToolUse',
231
+ permissionDecision: 'allow',
232
+ updatedInput: { answer },
233
+ },
234
+ });
235
+ }
236
+ // Timeout: allow without answer (CC shows question to user in terminal)
237
+ console.log(`Hooks: AskUserQuestion timeout for session ${sessionId} — allowing without answer`);
238
+ }
239
+ // Default: allow without modification
240
+ return reply.status(200).send({
241
+ hookSpecificOutput: {
242
+ hookEventName: 'PreToolUse',
243
+ permissionDecision: 'allow',
244
+ },
245
+ });
246
+ }
247
+ if (eventName === 'PermissionRequest') {
248
+ // Issue #284: Hook-based permission approval.
249
+ // Auto-approve modes respond immediately; others wait for client.
250
+ const permMode = session.permissionMode || 'default';
251
+ if (AUTO_APPROVE_MODES.has(permMode)) {
252
+ console.log(`Hooks: auto-approving PermissionRequest for session ${sessionId} (mode: ${permMode})`);
253
+ return reply.status(200).send({
254
+ hookSpecificOutput: {
255
+ hookEventName: 'PermissionRequest',
256
+ permissionDecision: 'allow',
257
+ },
258
+ });
259
+ }
260
+ // Non-auto-approve: wait for client to approve/reject via API.
261
+ // Store pending permission and block until resolved or timeout.
262
+ console.log(`Hooks: waiting for client permission decision for session ${sessionId}`);
263
+ const decision = await deps.sessions.waitForPermissionDecision(sessionId, PERMISSION_TIMEOUT_MS, toolName, permissionPrompt);
264
+ const decisionLabel = decision === 'allow' ? 'approved' : 'rejected';
265
+ console.log(`Hooks: PermissionRequest for session ${sessionId} — ${decisionLabel} by client`);
266
+ return reply.status(200).send({
267
+ hookSpecificOutput: {
268
+ hookEventName: 'PermissionRequest',
269
+ permissionDecision: decision,
270
+ },
271
+ });
272
+ }
273
+ return reply.status(200).send({ ok: true });
274
+ }
275
+ // Non-decision events: simple acknowledgement
276
+ return reply.status(200).send({ ok: true });
277
+ });
278
+ }
279
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * jsonl-watcher.ts — fs.watch()-based JSONL file watcher.
3
+ *
4
+ * Replaces polling-based JSONL reading in the monitor with near-instant
5
+ * file change detection. Uses fs.watch() with debouncing to avoid
6
+ * duplicate events from rapid writes.
7
+ *
8
+ * Issue #84: Replace JSONL polling with fs.watch.
9
+ */
10
+ import { type ParsedEntry } from './transcript.js';
11
+ export interface JsonlWatcherEvent {
12
+ sessionId: string;
13
+ messages: ParsedEntry[];
14
+ newOffset: number;
15
+ /** True if the file was truncated (e.g. after /clear). */
16
+ truncated: boolean;
17
+ }
18
+ export interface JsonlWatcherConfig {
19
+ /** Debounce interval in ms to coalesce rapid writes (default: 100). */
20
+ debounceMs: number;
21
+ }
22
+ /**
23
+ * Watches JSONL files for changes and emits parsed entries.
24
+ *
25
+ * Usage:
26
+ * const watcher = new JsonlWatcher();
27
+ * watcher.onEntries((event) => { ... });
28
+ * watcher.watch('session-123', '/path/to/session.jsonl', 0);
29
+ * watcher.unwatch('session-123');
30
+ * watcher.destroy();
31
+ */
32
+ export declare class JsonlWatcher {
33
+ private entries;
34
+ private listeners;
35
+ private config;
36
+ constructor(config?: Partial<JsonlWatcherConfig>);
37
+ /** Register a callback for new entries. */
38
+ onEntries(listener: (event: JsonlWatcherEvent) => void): () => void;
39
+ /** Start watching a JSONL file for a session.
40
+ * @param initialOffset - byte offset to start reading from (usually 0 or current session.monitorOffset).
41
+ */
42
+ watch(sessionId: string, jsonlPath: string, initialOffset: number): void;
43
+ /** Stop watching a session's JSONL file. */
44
+ unwatch(sessionId: string): void;
45
+ /** Update the offset for a session (e.g. after manual read during discovery). */
46
+ setOffset(sessionId: string, offset: number): void;
47
+ /** Check if a session is being watched. */
48
+ isWatching(sessionId: string): boolean;
49
+ /** Get the current offset for a watched session. */
50
+ getOffset(sessionId: string): number | undefined;
51
+ /** Stop all watchers and clean up. */
52
+ destroy(): void;
53
+ /** Schedule a debounced read for a session. */
54
+ private scheduleRead;
55
+ /** Read new bytes from the JSONL file and emit entries. */
56
+ private readAndEmit;
57
+ }