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.
- package/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
package/dist/pipeline.js
ADDED
|
@@ -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
|
+
}
|
package/dist/retry.d.ts
ADDED
|
@@ -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
|
+
}
|