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
@@ -0,0 +1,291 @@
1
+ /**
2
+ * pipeline.ts — Batch create and pipeline orchestration.
3
+ *
4
+ * Issue #36: Create multiple sessions in parallel, or define
5
+ * sequential pipelines with stage dependencies.
6
+ */
7
+ import { getErrorMessage } from './validation.js';
8
+ import { shouldRetry } from './error-categories.js';
9
+ import { retryWithJitter } from './retry.js';
10
+ export class PipelineManager {
11
+ sessions;
12
+ eventBus;
13
+ static PIPELINE_RETRY_MAX_ATTEMPTS = 3;
14
+ static PIPELINE_FIX_MAX_RETRIES = 3;
15
+ pipelines = new Map();
16
+ pipelineConfigs = new Map(); // #219: preserve original stage config
17
+ pollInterval = null;
18
+ cleanupTimers = new Map(); // #1092: track cleanup timers per pipeline
19
+ constructor(sessions, eventBus) {
20
+ this.sessions = sessions;
21
+ this.eventBus = eventBus;
22
+ }
23
+ /** Create multiple sessions in parallel. */
24
+ async batchCreate(specs) {
25
+ const results = await Promise.allSettled(specs.map(async (spec) => {
26
+ const session = await this.sessions.createSession({
27
+ workDir: spec.workDir,
28
+ name: spec.name,
29
+ permissionMode: spec.permissionMode,
30
+ autoApprove: spec.autoApprove,
31
+ stallThresholdMs: spec.stallThresholdMs,
32
+ });
33
+ let promptDelivery;
34
+ if (spec.prompt) {
35
+ promptDelivery = await this.sessions.sendInitialPrompt(session.id, spec.prompt);
36
+ }
37
+ return {
38
+ id: session.id,
39
+ name: session.windowName,
40
+ promptDelivery,
41
+ };
42
+ }));
43
+ const sessions = [];
44
+ const errors = [];
45
+ for (const result of results) {
46
+ if (result.status === 'fulfilled') {
47
+ sessions.push(result.value);
48
+ }
49
+ else {
50
+ errors.push(result.reason?.message || 'Unknown error');
51
+ }
52
+ }
53
+ return {
54
+ sessions,
55
+ created: sessions.length,
56
+ failed: errors.length,
57
+ errors,
58
+ };
59
+ }
60
+ /** Create a pipeline with stage dependencies. */
61
+ async createPipeline(config) {
62
+ const id = crypto.randomUUID();
63
+ // Validate: all dependsOn references must exist as stage names
64
+ const stageNames = new Set(config.stages.map(s => s.name));
65
+ for (const stage of config.stages) {
66
+ for (const dep of stage.dependsOn || []) {
67
+ if (!stageNames.has(dep)) {
68
+ throw new Error(`Stage "${stage.name}" depends on unknown stage "${dep}"`);
69
+ }
70
+ }
71
+ }
72
+ // Check for circular dependencies
73
+ this.detectCycles(config.stages);
74
+ const pipeline = {
75
+ id,
76
+ name: config.name,
77
+ currentStage: 'plan',
78
+ status: 'running',
79
+ retryCount: 0,
80
+ maxRetries: PipelineManager.PIPELINE_FIX_MAX_RETRIES,
81
+ stageHistory: [{ stage: 'plan', enteredAt: Date.now() }],
82
+ stages: config.stages.map(s => ({
83
+ name: s.name,
84
+ status: 'pending',
85
+ dependsOn: s.dependsOn || [],
86
+ })),
87
+ createdAt: Date.now(),
88
+ };
89
+ this.pipelines.set(id, pipeline);
90
+ this.pipelineConfigs.set(id, config); // #219: store original config for polling
91
+ // Start stages with no dependencies immediately
92
+ await this.advancePipeline(id, config);
93
+ // Start polling for stage completion
94
+ if (!this.pollInterval) {
95
+ this.pollInterval = setInterval(() => this.pollPipelines(), 5000);
96
+ }
97
+ return pipeline;
98
+ }
99
+ /** Get pipeline state. */
100
+ getPipeline(id) {
101
+ return this.pipelines.get(id) || null;
102
+ }
103
+ /** List all pipelines. */
104
+ listPipelines() {
105
+ return Array.from(this.pipelines.values());
106
+ }
107
+ /** Advance a pipeline: start stages whose dependencies are met. */
108
+ async advancePipeline(id, config) {
109
+ const pipeline = this.pipelines.get(id);
110
+ if (!pipeline || pipeline.status !== 'running')
111
+ return;
112
+ const completedStages = new Set(pipeline.stages.filter(s => s.status === 'completed').map(s => s.name));
113
+ const failedStages = pipeline.stages.filter(s => s.status === 'failed');
114
+ // If any stage failed, fail the pipeline
115
+ if (failedStages.length > 0) {
116
+ pipeline.status = 'failed';
117
+ this.transitionPipelineStage(pipeline, 'fix', { reason: 'stage_failed', failedStages: failedStages.map(s => s.name) });
118
+ return;
119
+ }
120
+ // Check if all stages are completed
121
+ if (pipeline.stages.every(s => s.status === 'completed')) {
122
+ pipeline.status = 'completed';
123
+ this.transitionPipelineStage(pipeline, 'submit', { reason: 'all_stages_completed' });
124
+ this.transitionPipelineStage(pipeline, 'done', { status: 'completed' });
125
+ if (this.eventBus) {
126
+ this.eventBus.emitEnded(id, 'pipeline_completed');
127
+ }
128
+ return;
129
+ }
130
+ // Start pending stages whose dependencies are all completed
131
+ for (const stage of pipeline.stages) {
132
+ if (stage.status !== 'pending')
133
+ continue;
134
+ const depsComplete = stage.dependsOn.every(d => completedStages.has(d));
135
+ if (!depsComplete)
136
+ continue;
137
+ // Find matching config stage
138
+ const stageConfig = config.stages.find(s => s.name === stage.name);
139
+ if (!stageConfig)
140
+ continue;
141
+ try {
142
+ const session = await retryWithJitter(async () => this.sessions.createSession({
143
+ workDir: stageConfig.workDir || config.workDir,
144
+ name: `pipeline-${config.name}-${stage.name}`,
145
+ permissionMode: stageConfig.permissionMode,
146
+ autoApprove: stageConfig.autoApprove,
147
+ }), {
148
+ maxAttempts: PipelineManager.PIPELINE_RETRY_MAX_ATTEMPTS,
149
+ shouldRetry: (error) => shouldRetry(error),
150
+ });
151
+ if (stageConfig.prompt) {
152
+ await retryWithJitter(async () => this.sessions.sendInitialPrompt(session.id, stageConfig.prompt), {
153
+ maxAttempts: PipelineManager.PIPELINE_RETRY_MAX_ATTEMPTS,
154
+ shouldRetry: (error) => shouldRetry(error),
155
+ });
156
+ }
157
+ stage.sessionId = session.id;
158
+ stage.status = 'running';
159
+ stage.startedAt = Date.now();
160
+ this.transitionPipelineStage(pipeline, 'execute', { stage: stage.name, sessionId: session.id });
161
+ }
162
+ catch (e) {
163
+ stage.status = 'failed';
164
+ stage.error = getErrorMessage(e);
165
+ pipeline.status = 'failed';
166
+ this.transitionPipelineStage(pipeline, 'fix', { stage: stage.name, error: stage.error });
167
+ }
168
+ }
169
+ const hasRunning = pipeline.stages.some(s => s.status === 'running');
170
+ const hasPending = pipeline.stages.some(s => s.status === 'pending');
171
+ if (hasRunning) {
172
+ this.transitionPipelineStage(pipeline, 'verify', { runningStages: pipeline.stages.filter(s => s.status === 'running').map(s => s.name) });
173
+ }
174
+ else if (hasPending) {
175
+ this.transitionPipelineStage(pipeline, 'plan', { pendingStages: pipeline.stages.filter(s => s.status === 'pending').map(s => s.name) });
176
+ }
177
+ }
178
+ /** Poll running pipelines and advance stages. */
179
+ async pollPipelines() {
180
+ // #830: Stop polling immediately when no pipelines remain, rather than
181
+ // waiting for the 30s cleanup setTimeout to fire. Prevents ~6 no-op poll
182
+ // cycles and stale config references during the cleanup window.
183
+ if (this.pipelines.size === 0) {
184
+ if (this.pollInterval) {
185
+ clearInterval(this.pollInterval);
186
+ this.pollInterval = null;
187
+ }
188
+ return;
189
+ }
190
+ for (const [id, pipeline] of this.pipelines) {
191
+ if (pipeline.status !== 'running')
192
+ continue;
193
+ // Check running stages for completion (idle status = done)
194
+ for (const stage of pipeline.stages) {
195
+ if (stage.status !== 'running' || !stage.sessionId)
196
+ continue;
197
+ const session = this.sessions.getSession(stage.sessionId);
198
+ if (!session) {
199
+ stage.status = 'failed';
200
+ stage.error = 'Session disappeared';
201
+ continue;
202
+ }
203
+ if (session.status === 'idle') {
204
+ stage.status = 'completed';
205
+ stage.completedAt = Date.now();
206
+ this.transitionPipelineStage(pipeline, 'verify', { stageCompleted: stage.name });
207
+ }
208
+ }
209
+ // #219: Use stored original config so stage prompt/permissionMode/autoApprove/workDir are preserved
210
+ const storedConfig = this.pipelineConfigs.get(id);
211
+ if (storedConfig) {
212
+ await this.advancePipeline(id, storedConfig);
213
+ }
214
+ // #221: Clean up completed/failed pipelines after 30s to avoid memory leak
215
+ // Note: advancePipeline may change status from 'running' to 'completed'/'failed'
216
+ // #1092: Track cleanup timer to prevent duplicates and allow destroy() cleanup
217
+ if (pipeline.status !== 'running' && !this.cleanupTimers.has(id)) {
218
+ const pipelineId = id;
219
+ const timer = setTimeout(() => {
220
+ this.cleanupTimers.delete(pipelineId);
221
+ this.pipelines.delete(pipelineId);
222
+ this.pipelineConfigs.delete(pipelineId); // #219: clean up stored config
223
+ // #578: Stop polling when no pipelines remain
224
+ if (this.pipelines.size === 0 && this.pollInterval) {
225
+ clearInterval(this.pollInterval);
226
+ this.pollInterval = null;
227
+ }
228
+ }, 30_000);
229
+ this.cleanupTimers.set(pipelineId, timer);
230
+ }
231
+ }
232
+ }
233
+ transitionPipelineStage(pipeline, stage, output) {
234
+ if (pipeline.currentStage === stage)
235
+ return;
236
+ const now = Date.now();
237
+ const previous = pipeline.stageHistory[pipeline.stageHistory.length - 1];
238
+ if (previous && previous.exitedAt === undefined) {
239
+ previous.exitedAt = now;
240
+ if (output !== undefined)
241
+ previous.output = output;
242
+ }
243
+ pipeline.currentStage = stage;
244
+ pipeline.stageHistory.push({ stage, enteredAt: now });
245
+ if (stage === 'fix') {
246
+ pipeline.retryCount += 1;
247
+ if (pipeline.retryCount > pipeline.maxRetries) {
248
+ pipeline.status = 'failed';
249
+ }
250
+ }
251
+ }
252
+ /** Detect circular dependencies. Throws if found. */
253
+ detectCycles(stages) {
254
+ const graph = new Map();
255
+ for (const stage of stages) {
256
+ graph.set(stage.name, stage.dependsOn || []);
257
+ }
258
+ const visited = new Set();
259
+ const inStack = new Set();
260
+ const dfs = (node) => {
261
+ if (inStack.has(node))
262
+ throw new Error(`Circular dependency detected involving stage "${node}"`);
263
+ if (visited.has(node))
264
+ return;
265
+ inStack.add(node);
266
+ visited.add(node);
267
+ for (const dep of graph.get(node) || []) {
268
+ dfs(dep);
269
+ }
270
+ inStack.delete(node);
271
+ };
272
+ for (const stage of stages) {
273
+ dfs(stage.name);
274
+ }
275
+ }
276
+ /** Clean up. */
277
+ destroy() {
278
+ if (this.pollInterval) {
279
+ clearInterval(this.pollInterval);
280
+ this.pollInterval = null;
281
+ }
282
+ // #1092: Clear all pending cleanup timers
283
+ for (const timer of this.cleanupTimers.values()) {
284
+ clearTimeout(timer);
285
+ }
286
+ this.cleanupTimers.clear();
287
+ // #1092: Clear maps to release memory
288
+ this.pipelines.clear();
289
+ this.pipelineConfigs.clear();
290
+ }
291
+ }
@@ -0,0 +1,4 @@
1
+ export declare function buildWindowsFindPidOnPortScript(port: number): string;
2
+ export declare function buildWindowsReadParentPidScript(pid: number): string;
3
+ export declare function findPidOnPort(port: number): Promise<number[]>;
4
+ export declare function readParentPid(pid: number): Promise<number | null>;
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ const SUBPROCESS_TIMEOUT_MS = 5_000;
4
+ function runCommand(command, args) {
5
+ return new Promise((resolve, reject) => {
6
+ execFile(command, args, { encoding: 'utf-8', timeout: SUBPROCESS_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout) => {
7
+ if (error) {
8
+ reject(error);
9
+ return;
10
+ }
11
+ resolve(stdout);
12
+ });
13
+ });
14
+ }
15
+ function parsePidLines(output) {
16
+ return [...new Set(output
17
+ .trim()
18
+ .split(/\r?\n/)
19
+ .map(line => parseInt(line.trim(), 10))
20
+ .filter(pid => Number.isInteger(pid) && pid > 0))];
21
+ }
22
+ export function buildWindowsFindPidOnPortScript(port) {
23
+ return [
24
+ `Get-NetTCPConnection -State Listen -LocalPort ${port} -ErrorAction SilentlyContinue`,
25
+ 'Select-Object -ExpandProperty OwningProcess -Unique',
26
+ ].join(' | ');
27
+ }
28
+ export function buildWindowsReadParentPidScript(pid) {
29
+ return [
30
+ `Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue`,
31
+ 'Select-Object -ExpandProperty ParentProcessId',
32
+ ].join(' | ');
33
+ }
34
+ export async function findPidOnPort(port) {
35
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
36
+ return [];
37
+ try {
38
+ if (process.platform === 'win32') {
39
+ const script = buildWindowsFindPidOnPortScript(port);
40
+ const stdout = await runCommand('powershell', ['-NoProfile', '-Command', script]);
41
+ return parsePidLines(stdout);
42
+ }
43
+ const stdout = await runCommand('lsof', ['-ti', `tcp:${port}`]);
44
+ return parsePidLines(stdout);
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ }
50
+ export async function readParentPid(pid) {
51
+ if (!Number.isInteger(pid) || pid <= 0)
52
+ return null;
53
+ try {
54
+ if (process.platform === 'win32') {
55
+ const script = buildWindowsReadParentPidScript(pid);
56
+ const stdout = await runCommand('powershell', ['-NoProfile', '-Command', script]);
57
+ const parent = parseInt(stdout.trim(), 10);
58
+ return Number.isInteger(parent) && parent > 0 ? parent : null;
59
+ }
60
+ if (process.platform !== 'linux') {
61
+ return null;
62
+ }
63
+ const status = await readFile(`/proc/${pid}/status`, 'utf-8');
64
+ const match = status.match(/^PPid:\s+(\d+)/m);
65
+ if (!match)
66
+ return null;
67
+ const parent = parseInt(match[1], 10);
68
+ return Number.isInteger(parent) && parent > 0 ? parent : null;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Question Manager: Manages pending user questions and their lifecycle.
3
+ * Issue #336: Store a pending AskUserQuestion and return a promise that
4
+ * resolves when the external client provides an answer via POST /answer.
5
+ *
6
+ * Phase 2: Extracted from session.ts as part of Issue #351 decomposition.
7
+ */
8
+ export declare class QuestionManager {
9
+ private pendingQuestions;
10
+ /**
11
+ * Store a pending AskUserQuestion and return a promise that resolves
12
+ * when the external client provides an answer via POST /answer.
13
+ *
14
+ * @param sessionId - Aegis session ID
15
+ * @param toolUseId - Unique tool use ID for this question
16
+ * @param question - The question text to ask the user
17
+ * @param timeoutMs - Timeout before resolving with null (default 30_000ms)
18
+ * @returns Promise that resolves with the user's answer or null on timeout
19
+ */
20
+ waitForAnswer(sessionId: string, toolUseId: string, question: string, timeoutMs?: number): Promise<string | null>;
21
+ /**
22
+ * Submit an answer to a pending question.
23
+ *
24
+ * @param sessionId - Aegis session ID
25
+ * @param questionId - Tool use ID of the question (for verification)
26
+ * @param answer - The user's answer
27
+ * @returns True if the question was resolved, false if not found or ID mismatch
28
+ */
29
+ submitAnswer(sessionId: string, questionId: string, answer: string): boolean;
30
+ /**
31
+ * Check if a session has a pending question.
32
+ *
33
+ * @param sessionId - Aegis session ID
34
+ * @returns True if a pending question exists for this session
35
+ */
36
+ hasPendingQuestion(sessionId: string): boolean;
37
+ /**
38
+ * Get info about a pending question (for API responses).
39
+ *
40
+ * @param sessionId - Aegis session ID
41
+ * @returns Object with toolUseId, question, and timestamp, or null if no pending question
42
+ */
43
+ getPendingQuestionInfo(sessionId: string): {
44
+ toolUseId: string;
45
+ question: string;
46
+ timestamp: number;
47
+ } | null;
48
+ /**
49
+ * Clean up any pending question for a session (e.g. on session delete).
50
+ *
51
+ * @param sessionId - Aegis session ID
52
+ */
53
+ cleanupPendingQuestion(sessionId: string): void;
54
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Question Manager: Manages pending user questions and their lifecycle.
3
+ * Issue #336: Store a pending AskUserQuestion and return a promise that
4
+ * resolves when the external client provides an answer via POST /answer.
5
+ *
6
+ * Phase 2: Extracted from session.ts as part of Issue #351 decomposition.
7
+ */
8
+ export class QuestionManager {
9
+ pendingQuestions = new Map();
10
+ /**
11
+ * Store a pending AskUserQuestion and return a promise that resolves
12
+ * when the external client provides an answer via POST /answer.
13
+ *
14
+ * @param sessionId - Aegis session ID
15
+ * @param toolUseId - Unique tool use ID for this question
16
+ * @param question - The question text to ask the user
17
+ * @param timeoutMs - Timeout before resolving with null (default 30_000ms)
18
+ * @returns Promise that resolves with the user's answer or null on timeout
19
+ */
20
+ waitForAnswer(sessionId, toolUseId, question, timeoutMs = 30_000) {
21
+ return new Promise((resolve) => {
22
+ const timer = setTimeout(() => {
23
+ this.pendingQuestions.delete(sessionId);
24
+ console.log(`Hooks: AskUserQuestion timeout for session ${sessionId} — allowing without answer`);
25
+ resolve(null);
26
+ }, timeoutMs);
27
+ this.pendingQuestions.set(sessionId, { resolve, timer, toolUseId, question, timestamp: Date.now() });
28
+ });
29
+ }
30
+ /**
31
+ * Submit an answer to a pending question.
32
+ *
33
+ * @param sessionId - Aegis session ID
34
+ * @param questionId - Tool use ID of the question (for verification)
35
+ * @param answer - The user's answer
36
+ * @returns True if the question was resolved, false if not found or ID mismatch
37
+ */
38
+ submitAnswer(sessionId, questionId, answer) {
39
+ const pending = this.pendingQuestions.get(sessionId);
40
+ if (!pending)
41
+ return false;
42
+ if (pending.toolUseId !== questionId)
43
+ return false;
44
+ clearTimeout(pending.timer);
45
+ this.pendingQuestions.delete(sessionId);
46
+ pending.resolve(answer);
47
+ return true;
48
+ }
49
+ /**
50
+ * Check if a session has a pending question.
51
+ *
52
+ * @param sessionId - Aegis session ID
53
+ * @returns True if a pending question exists for this session
54
+ */
55
+ hasPendingQuestion(sessionId) {
56
+ return this.pendingQuestions.has(sessionId);
57
+ }
58
+ /**
59
+ * Get info about a pending question (for API responses).
60
+ *
61
+ * @param sessionId - Aegis session ID
62
+ * @returns Object with toolUseId, question, and timestamp, or null if no pending question
63
+ */
64
+ getPendingQuestionInfo(sessionId) {
65
+ const pending = this.pendingQuestions.get(sessionId);
66
+ return pending ? { toolUseId: pending.toolUseId, question: pending.question, timestamp: pending.timestamp } : null;
67
+ }
68
+ /**
69
+ * Clean up any pending question for a session (e.g. on session delete).
70
+ *
71
+ * @param sessionId - Aegis session ID
72
+ */
73
+ cleanupPendingQuestion(sessionId) {
74
+ const pending = this.pendingQuestions.get(sessionId);
75
+ if (pending) {
76
+ clearTimeout(pending.timer);
77
+ this.pendingQuestions.delete(sessionId);
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * retry.ts — shared retry helper with bounded exponential backoff + jitter.
3
+ */
4
+ export interface RetryOptions {
5
+ maxAttempts?: number;
6
+ baseDelayMs?: number;
7
+ maxDelayMs?: number;
8
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
9
+ onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
10
+ }
11
+ export declare function retryWithJitter<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
package/dist/retry.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * retry.ts — shared retry helper with bounded exponential backoff + jitter.
3
+ */
4
+ function sleep(ms) {
5
+ return new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+ function computeDelayMs(attempt, baseDelayMs, maxDelayMs) {
8
+ const exponential = Math.min(baseDelayMs * (2 ** (attempt - 1)), maxDelayMs);
9
+ const jitterMultiplier = 0.5 + (Math.random() * 0.5);
10
+ return Math.round(exponential * jitterMultiplier);
11
+ }
12
+ export async function retryWithJitter(fn, options = {}) {
13
+ const maxAttempts = options.maxAttempts ?? 3;
14
+ const baseDelayMs = options.baseDelayMs ?? 250;
15
+ const maxDelayMs = options.maxDelayMs ?? 3_000;
16
+ let lastError;
17
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
18
+ try {
19
+ return await fn();
20
+ }
21
+ catch (error) {
22
+ lastError = error;
23
+ const isLastAttempt = attempt >= maxAttempts;
24
+ const canRetry = options.shouldRetry ? options.shouldRetry(error, attempt) : true;
25
+ if (isLastAttempt || !canRetry) {
26
+ throw error;
27
+ }
28
+ const delayMs = computeDelayMs(attempt, baseDelayMs, maxDelayMs);
29
+ options.onRetry?.(error, attempt, delayMs);
30
+ await sleep(delayMs);
31
+ }
32
+ }
33
+ throw lastError;
34
+ }
@@ -0,0 +1,12 @@
1
+ import type { ZodType } from 'zod';
2
+ export type SafeJsonResult<T> = {
3
+ ok: true;
4
+ data: T;
5
+ } | {
6
+ ok: false;
7
+ error: string;
8
+ };
9
+ /** Parse JSON without throwing and return a contextual error message. */
10
+ export declare function safeJsonParse(raw: string, context?: string): SafeJsonResult<unknown>;
11
+ /** Parse JSON and validate the resulting structure with a Zod schema. */
12
+ export declare function safeJsonParseSchema<T>(raw: string, schema: ZodType<T>, context?: string): SafeJsonResult<T>;
@@ -0,0 +1,22 @@
1
+ import { getErrorMessage } from './validation.js';
2
+ /** Parse JSON without throwing and return a contextual error message. */
3
+ export function safeJsonParse(raw, context = 'JSON payload') {
4
+ try {
5
+ return { ok: true, data: JSON.parse(raw) };
6
+ }
7
+ catch (err) {
8
+ return { ok: false, error: `${context} is not valid JSON: ${getErrorMessage(err)}` };
9
+ }
10
+ }
11
+ /** Parse JSON and validate the resulting structure with a Zod schema. */
12
+ export function safeJsonParseSchema(raw, schema, context = 'JSON payload') {
13
+ const parsed = safeJsonParse(raw, context);
14
+ if (!parsed.ok)
15
+ return parsed;
16
+ const validated = schema.safeParse(parsed.data);
17
+ if (!validated.success) {
18
+ const reason = validated.error.issues.map(i => i.message).join(', ');
19
+ return { ok: false, error: `${context} has invalid structure: ${reason}` };
20
+ }
21
+ return { ok: true, data: validated.data };
22
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * screenshot.ts — Headless screenshot capture via Playwright.
3
+ *
4
+ * Issue #22: Visual verification for CC sessions.
5
+ * Uses Playwright if available; returns 501 Not Implemented otherwise.
6
+ */
7
+ export interface ScreenshotOptions {
8
+ url: string;
9
+ fullPage?: boolean;
10
+ width?: number;
11
+ height?: number;
12
+ /** Chromium --host-resolver-rules value to pin DNS (prevents TOCTOU rebinding). */
13
+ hostResolverRule?: string;
14
+ }
15
+ export interface ScreenshotResult {
16
+ screenshot: string;
17
+ timestamp: string;
18
+ url: string;
19
+ width: number;
20
+ height: number;
21
+ }
22
+ /**
23
+ * Capture a screenshot of the given URL using headless Chromium.
24
+ * Returns the result or throws if Playwright is not available.
25
+ */
26
+ export declare function captureScreenshot(opts: ScreenshotOptions): Promise<ScreenshotResult>;
27
+ /** Check if Playwright is available for screenshot capture. */
28
+ export declare function isPlaywrightAvailable(): boolean;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * screenshot.ts — Headless screenshot capture via Playwright.
3
+ *
4
+ * Issue #22: Visual verification for CC sessions.
5
+ * Uses Playwright if available; returns 501 Not Implemented otherwise.
6
+ */
7
+ let playwrightAvailable = false;
8
+ let chromium = null;
9
+ // Lazy-load Playwright — only fails at startup, not import time
10
+ try {
11
+ const pw = await import('playwright');
12
+ chromium = pw.chromium;
13
+ playwrightAvailable = true;
14
+ }
15
+ catch { /* playwright not installed — screenshot feature disabled */
16
+ playwrightAvailable = false;
17
+ }
18
+ /**
19
+ * Capture a screenshot of the given URL using headless Chromium.
20
+ * Returns the result or throws if Playwright is not available.
21
+ */
22
+ export async function captureScreenshot(opts) {
23
+ if (!playwrightAvailable || !chromium) {
24
+ throw new Error('Playwright is not installed. Install it with: npx playwright install chromium && npm install -D playwright');
25
+ }
26
+ const launchOptions = { headless: true };
27
+ if (opts.hostResolverRule) {
28
+ launchOptions.args = [`--host-resolver-rules=${opts.hostResolverRule}`];
29
+ }
30
+ const browser = await chromium.launch(launchOptions);
31
+ try {
32
+ const context = await browser.newContext({
33
+ viewport: {
34
+ width: opts.width || 1280,
35
+ height: opts.height || 720,
36
+ },
37
+ });
38
+ const page = await context.newPage();
39
+ await page.goto(opts.url, { waitUntil: 'load', timeout: 30_000 });
40
+ const buffer = await page.screenshot({
41
+ fullPage: opts.fullPage || false,
42
+ type: 'png',
43
+ });
44
+ await context.close();
45
+ return {
46
+ screenshot: buffer.toString('base64'),
47
+ timestamp: new Date().toISOString(),
48
+ url: opts.url,
49
+ width: opts.width || 1280,
50
+ height: opts.height || 720,
51
+ };
52
+ }
53
+ finally {
54
+ await browser.close();
55
+ }
56
+ }
57
+ /** Check if Playwright is available for screenshot capture. */
58
+ export function isPlaywrightAvailable() {
59
+ return playwrightAvailable;
60
+ }