@siftd/connect-agent 0.2.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * WebSocket Client for Real-Time Communication
3
+ *
4
+ * Provides bidirectional streaming between agent and server.
5
+ * - Receives messages instantly (no polling delay)
6
+ * - Streams responses as they're generated
7
+ * - Supports interruption and progress updates
8
+ */
9
+ export type MessageHandler = (message: WebSocketMessage) => Promise<void>;
10
+ export type StreamHandler = (chunk: string) => void;
11
+ export interface WebSocketMessage {
12
+ type: 'message' | 'interrupt' | 'ping' | 'pong' | 'connected';
13
+ id?: string;
14
+ content?: string;
15
+ timestamp?: number;
16
+ apiKey?: string;
17
+ }
18
+ export interface StreamingResponse {
19
+ send: (chunk: string) => void;
20
+ done: () => void;
21
+ error: (message: string) => void;
22
+ }
23
+ export declare class AgentWebSocket {
24
+ private ws;
25
+ private serverUrl;
26
+ private token;
27
+ private messageHandler;
28
+ private reconnectAttempts;
29
+ private maxReconnectAttempts;
30
+ private reconnectDelay;
31
+ private pingInterval;
32
+ private isConnected;
33
+ private pendingResponses;
34
+ private cloudflareBlocked;
35
+ constructor();
36
+ /**
37
+ * Connect to the WebSocket server
38
+ */
39
+ connect(): Promise<boolean>;
40
+ /**
41
+ * Set handler for incoming messages
42
+ */
43
+ onMessage(handler: MessageHandler): void;
44
+ /**
45
+ * Send a streaming response for a message
46
+ */
47
+ createStreamingResponse(messageId: string): StreamingResponse;
48
+ /**
49
+ * Send a complete response (non-streaming)
50
+ */
51
+ sendResponse(messageId: string, content: string): void;
52
+ /**
53
+ * Send progress update
54
+ */
55
+ sendProgress(messageId: string, status: string): void;
56
+ /**
57
+ * Send typing indicator
58
+ */
59
+ sendTyping(isTyping: boolean): void;
60
+ /**
61
+ * Check if connected
62
+ */
63
+ connected(): boolean;
64
+ /**
65
+ * Close the connection
66
+ */
67
+ close(): void;
68
+ private handleMessage;
69
+ private sendToServer;
70
+ private startPingInterval;
71
+ private stopPingInterval;
72
+ private attemptReconnect;
73
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * WebSocket Client for Real-Time Communication
3
+ *
4
+ * Provides bidirectional streaming between agent and server.
5
+ * - Receives messages instantly (no polling delay)
6
+ * - Streams responses as they're generated
7
+ * - Supports interruption and progress updates
8
+ */
9
+ import WebSocket from 'ws';
10
+ import { getServerUrl, getAgentToken } from './config.js';
11
+ export class AgentWebSocket {
12
+ ws = null;
13
+ serverUrl;
14
+ token;
15
+ messageHandler = null;
16
+ reconnectAttempts = 0;
17
+ maxReconnectAttempts = 3; // Reduced - don't spam retries, polling works fine
18
+ reconnectDelay = 1000;
19
+ pingInterval = null;
20
+ isConnected = false;
21
+ pendingResponses = new Map();
22
+ cloudflareBlocked = false; // Track if Cloudflare is blocking WebSockets
23
+ constructor() {
24
+ const httpUrl = getServerUrl();
25
+ // Check for WebSocket-specific URL (bypasses Cloudflare)
26
+ const wsOverride = process.env.CONNECT_WS_URL;
27
+ if (wsOverride) {
28
+ this.serverUrl = wsOverride;
29
+ }
30
+ else {
31
+ // Convert http(s) to ws(s)
32
+ this.serverUrl = httpUrl.replace(/^http/, 'ws') + '/api/agent/ws';
33
+ }
34
+ this.token = getAgentToken() || '';
35
+ }
36
+ /**
37
+ * Connect to the WebSocket server
38
+ */
39
+ async connect() {
40
+ return new Promise((resolve) => {
41
+ try {
42
+ console.log('[WS] Connecting to', this.serverUrl);
43
+ this.ws = new WebSocket(this.serverUrl, {
44
+ headers: {
45
+ 'Authorization': `Bearer ${this.token}`
46
+ }
47
+ });
48
+ this.ws.on('open', () => {
49
+ console.log('[WS] Connected');
50
+ this.isConnected = true;
51
+ this.reconnectAttempts = 0;
52
+ this.startPingInterval();
53
+ resolve(true);
54
+ });
55
+ this.ws.on('message', (data) => {
56
+ this.handleMessage(data.toString());
57
+ });
58
+ this.ws.on('close', (code, reason) => {
59
+ // Don't log disconnects if Cloudflare is blocking (polling is working)
60
+ if (!this.cloudflareBlocked) {
61
+ console.log(`[WS] Disconnected (${code}): ${reason}`);
62
+ }
63
+ this.isConnected = false;
64
+ this.stopPingInterval();
65
+ this.attemptReconnect();
66
+ });
67
+ this.ws.on('error', (error) => {
68
+ // Check for Cloudflare 524 timeout - don't spam logs
69
+ if (error.message.includes('524') || error.message.includes('Unexpected server response')) {
70
+ if (!this.cloudflareBlocked) {
71
+ this.cloudflareBlocked = true;
72
+ // Only log once, not every retry
73
+ }
74
+ }
75
+ else {
76
+ console.error('[WS] Error:', error.message);
77
+ }
78
+ if (!this.isConnected) {
79
+ resolve(false);
80
+ }
81
+ });
82
+ // Timeout for initial connection
83
+ setTimeout(() => {
84
+ if (!this.isConnected) {
85
+ console.log('[WS] Connection timeout');
86
+ resolve(false);
87
+ }
88
+ }, 10000);
89
+ }
90
+ catch (error) {
91
+ console.error('[WS] Connection failed:', error);
92
+ resolve(false);
93
+ }
94
+ });
95
+ }
96
+ /**
97
+ * Set handler for incoming messages
98
+ */
99
+ onMessage(handler) {
100
+ this.messageHandler = handler;
101
+ }
102
+ /**
103
+ * Send a streaming response for a message
104
+ */
105
+ createStreamingResponse(messageId) {
106
+ const response = {
107
+ send: (chunk) => {
108
+ this.sendToServer({
109
+ type: 'stream',
110
+ messageId,
111
+ chunk
112
+ });
113
+ },
114
+ done: () => {
115
+ this.sendToServer({
116
+ type: 'stream_end',
117
+ messageId
118
+ });
119
+ this.pendingResponses.delete(messageId);
120
+ },
121
+ error: (message) => {
122
+ this.sendToServer({
123
+ type: 'stream_error',
124
+ messageId,
125
+ error: message
126
+ });
127
+ this.pendingResponses.delete(messageId);
128
+ }
129
+ };
130
+ this.pendingResponses.set(messageId, response);
131
+ return response;
132
+ }
133
+ /**
134
+ * Send a complete response (non-streaming)
135
+ */
136
+ sendResponse(messageId, content) {
137
+ this.sendToServer({
138
+ type: 'response',
139
+ messageId,
140
+ content
141
+ });
142
+ }
143
+ /**
144
+ * Send progress update
145
+ */
146
+ sendProgress(messageId, status) {
147
+ this.sendToServer({
148
+ type: 'progress',
149
+ messageId,
150
+ status
151
+ });
152
+ }
153
+ /**
154
+ * Send typing indicator
155
+ */
156
+ sendTyping(isTyping) {
157
+ this.sendToServer({
158
+ type: 'typing',
159
+ isTyping
160
+ });
161
+ }
162
+ /**
163
+ * Check if connected
164
+ */
165
+ connected() {
166
+ return this.isConnected && this.ws?.readyState === WebSocket.OPEN;
167
+ }
168
+ /**
169
+ * Close the connection
170
+ */
171
+ close() {
172
+ this.stopPingInterval();
173
+ this.maxReconnectAttempts = 0; // Prevent reconnection
174
+ if (this.ws) {
175
+ this.ws.close();
176
+ this.ws = null;
177
+ }
178
+ this.isConnected = false;
179
+ }
180
+ handleMessage(data) {
181
+ try {
182
+ const message = JSON.parse(data);
183
+ switch (message.type) {
184
+ case 'ping':
185
+ this.sendToServer({ type: 'pong' });
186
+ break;
187
+ case 'pong':
188
+ // Expected response to our ping, ignore silently
189
+ break;
190
+ case 'connected':
191
+ console.log('[WS] Server confirmed connection');
192
+ break;
193
+ case 'interrupt':
194
+ console.log('[WS] Received interrupt for message:', message.id);
195
+ // TODO: Cancel ongoing work for this message
196
+ break;
197
+ case 'message':
198
+ if (this.messageHandler && message.content) {
199
+ this.messageHandler(message);
200
+ }
201
+ break;
202
+ default:
203
+ console.log('[WS] Unknown message type:', message.type);
204
+ }
205
+ }
206
+ catch (error) {
207
+ console.error('[WS] Failed to parse message:', error);
208
+ }
209
+ }
210
+ sendToServer(data) {
211
+ if (this.ws?.readyState === WebSocket.OPEN) {
212
+ this.ws.send(JSON.stringify(data));
213
+ }
214
+ }
215
+ startPingInterval() {
216
+ this.pingInterval = setInterval(() => {
217
+ if (this.connected()) {
218
+ this.sendToServer({ type: 'ping' });
219
+ }
220
+ }, 30000);
221
+ }
222
+ stopPingInterval() {
223
+ if (this.pingInterval) {
224
+ clearInterval(this.pingInterval);
225
+ this.pingInterval = null;
226
+ }
227
+ }
228
+ attemptReconnect() {
229
+ // Don't retry if Cloudflare is blocking WebSockets
230
+ if (this.cloudflareBlocked) {
231
+ return;
232
+ }
233
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
234
+ // Silent - polling is working fine
235
+ return;
236
+ }
237
+ this.reconnectAttempts++;
238
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
239
+ setTimeout(() => {
240
+ this.connect();
241
+ }, delay);
242
+ }
243
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Claude Code Worker Manager
3
+ * Spawns and manages Claude Code CLI instances for parallel task execution
4
+ */
5
+ import { WorkerJob, SpawnOptions, WorkerConfig } from './types.js';
6
+ export declare class WorkerManager {
7
+ private config;
8
+ private activeWorkers;
9
+ constructor(workspaceDir: string, configOverrides?: Partial<WorkerConfig>);
10
+ /**
11
+ * Generate a unique job ID
12
+ */
13
+ private generateJobId;
14
+ /**
15
+ * Get job file path
16
+ */
17
+ private getJobPath;
18
+ /**
19
+ * Save job state
20
+ */
21
+ private saveJob;
22
+ /**
23
+ * Count currently running workers
24
+ */
25
+ private countRunning;
26
+ /**
27
+ * Spawn a new Claude Code worker
28
+ */
29
+ spawn(task: string, options?: SpawnOptions): Promise<string>;
30
+ /**
31
+ * Get job status
32
+ */
33
+ get(jobId: string): WorkerJob | null;
34
+ /**
35
+ * Wait for a job to complete
36
+ */
37
+ wait(jobId: string, maxWait?: number): Promise<WorkerJob>;
38
+ /**
39
+ * List jobs with optional filter
40
+ */
41
+ list(filter?: {
42
+ status?: string;
43
+ }): WorkerJob[];
44
+ /**
45
+ * Cancel a running job
46
+ */
47
+ cancel(jobId: string): boolean;
48
+ /**
49
+ * Clean up old completed/failed jobs
50
+ */
51
+ cleanup(olderThanMs?: number): number;
52
+ /**
53
+ * Get summary of all jobs
54
+ */
55
+ summary(): {
56
+ total: number;
57
+ running: number;
58
+ completed: number;
59
+ failed: number;
60
+ pending: number;
61
+ };
62
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Claude Code Worker Manager
3
+ * Spawns and manages Claude Code CLI instances for parallel task execution
4
+ */
5
+ import { spawn } from 'child_process';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { DEFAULT_WORKER_CONFIG } from './types.js';
9
+ export class WorkerManager {
10
+ config;
11
+ activeWorkers = new Map();
12
+ constructor(workspaceDir, configOverrides) {
13
+ this.config = {
14
+ ...DEFAULT_WORKER_CONFIG,
15
+ ...configOverrides,
16
+ jobsDir: path.join(workspaceDir, 'jobs')
17
+ };
18
+ // Ensure jobs directory exists
19
+ if (!fs.existsSync(this.config.jobsDir)) {
20
+ fs.mkdirSync(this.config.jobsDir, { recursive: true });
21
+ }
22
+ }
23
+ /**
24
+ * Generate a unique job ID
25
+ */
26
+ generateJobId() {
27
+ return `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
28
+ }
29
+ /**
30
+ * Get job file path
31
+ */
32
+ getJobPath(jobId) {
33
+ return path.join(this.config.jobsDir, `${jobId}.json`);
34
+ }
35
+ /**
36
+ * Save job state
37
+ */
38
+ saveJob(job) {
39
+ fs.writeFileSync(this.getJobPath(job.id), JSON.stringify(job, null, 2));
40
+ }
41
+ /**
42
+ * Count currently running workers
43
+ */
44
+ countRunning() {
45
+ const jobs = this.list({ status: 'running' });
46
+ return jobs.length;
47
+ }
48
+ /**
49
+ * Spawn a new Claude Code worker
50
+ */
51
+ async spawn(task, options = {}) {
52
+ const { workspace = process.cwd(), timeout = this.config.defaultTimeout, priority = 'normal' } = options;
53
+ // Check parallel limit
54
+ const runningCount = this.countRunning();
55
+ if (runningCount >= this.config.maxParallel) {
56
+ throw new Error(`Maximum parallel workers (${this.config.maxParallel}) reached. ${runningCount} workers running.`);
57
+ }
58
+ // Validate timeout
59
+ const effectiveTimeout = Math.min(timeout, this.config.maxTimeout);
60
+ const jobId = this.generateJobId();
61
+ const job = {
62
+ id: jobId,
63
+ task,
64
+ workspace,
65
+ status: 'pending',
66
+ priority,
67
+ created: new Date().toISOString(),
68
+ timeout: effectiveTimeout
69
+ };
70
+ this.saveJob(job);
71
+ // Spawn Claude CLI process
72
+ try {
73
+ // Escape single quotes in task for shell safety
74
+ const escapedTask = task.replace(/'/g, "'\\''");
75
+ // Spawn using bash -l -c to ensure proper PATH resolution (NVM, etc.)
76
+ // This is more reliable than shell: true which has quote escaping issues
77
+ const child = spawn('bash', ['-l', '-c', `claude -p '${escapedTask}' --output-format text --dangerously-skip-permissions`], {
78
+ cwd: workspace,
79
+ detached: true,
80
+ stdio: ['ignore', 'pipe', 'pipe'],
81
+ env: {
82
+ ...process.env,
83
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
84
+ GH_TOKEN: process.env.GH_TOKEN,
85
+ GITHUB_TOKEN: process.env.GITHUB_TOKEN
86
+ }
87
+ });
88
+ job.status = 'running';
89
+ job.started = new Date().toISOString();
90
+ job.pid = child.pid;
91
+ this.saveJob(job);
92
+ this.activeWorkers.set(jobId, child);
93
+ let stdout = '';
94
+ let stderr = '';
95
+ child.stdout?.on('data', (data) => {
96
+ stdout += data.toString();
97
+ });
98
+ child.stderr?.on('data', (data) => {
99
+ stderr += data.toString();
100
+ });
101
+ // Set up timeout
102
+ const timeoutHandle = setTimeout(() => {
103
+ if (child.pid && !child.killed) {
104
+ child.kill('SIGTERM');
105
+ job.status = 'timeout';
106
+ job.error = `Job timed out after ${effectiveTimeout}ms`;
107
+ job.completed = new Date().toISOString();
108
+ job.result = stdout || stderr;
109
+ this.saveJob(job);
110
+ this.activeWorkers.delete(jobId);
111
+ }
112
+ }, effectiveTimeout);
113
+ child.on('close', (code) => {
114
+ clearTimeout(timeoutHandle);
115
+ this.activeWorkers.delete(jobId);
116
+ // Reload job in case it was updated (e.g., cancelled)
117
+ const currentJob = this.get(jobId);
118
+ if (currentJob && currentJob.status === 'running') {
119
+ currentJob.status = code === 0 ? 'completed' : 'failed';
120
+ currentJob.completed = new Date().toISOString();
121
+ currentJob.exitCode = code ?? undefined;
122
+ currentJob.result = stdout.trim();
123
+ if (stderr && code !== 0) {
124
+ currentJob.error = stderr.trim();
125
+ }
126
+ this.saveJob(currentJob);
127
+ }
128
+ });
129
+ child.on('error', (err) => {
130
+ clearTimeout(timeoutHandle);
131
+ this.activeWorkers.delete(jobId);
132
+ job.status = 'failed';
133
+ job.error = err.message;
134
+ job.completed = new Date().toISOString();
135
+ this.saveJob(job);
136
+ });
137
+ // Detach the child process
138
+ child.unref();
139
+ }
140
+ catch (error) {
141
+ job.status = 'failed';
142
+ job.error = error instanceof Error ? error.message : String(error);
143
+ job.completed = new Date().toISOString();
144
+ this.saveJob(job);
145
+ }
146
+ return jobId;
147
+ }
148
+ /**
149
+ * Get job status
150
+ */
151
+ get(jobId) {
152
+ const jobPath = this.getJobPath(jobId);
153
+ if (!fs.existsSync(jobPath)) {
154
+ return null;
155
+ }
156
+ try {
157
+ return JSON.parse(fs.readFileSync(jobPath, 'utf8'));
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ /**
164
+ * Wait for a job to complete
165
+ */
166
+ async wait(jobId, maxWait = 300000) {
167
+ const pollInterval = 1000;
168
+ const start = Date.now();
169
+ while (Date.now() - start < maxWait) {
170
+ const job = this.get(jobId);
171
+ if (!job) {
172
+ throw new Error(`Job ${jobId} not found`);
173
+ }
174
+ if (job.status !== 'running' && job.status !== 'pending') {
175
+ return job;
176
+ }
177
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
178
+ }
179
+ throw new Error(`Job ${jobId} did not complete within ${maxWait}ms`);
180
+ }
181
+ /**
182
+ * List jobs with optional filter
183
+ */
184
+ list(filter) {
185
+ if (!fs.existsSync(this.config.jobsDir)) {
186
+ return [];
187
+ }
188
+ const files = fs.readdirSync(this.config.jobsDir).filter(f => f.endsWith('.json'));
189
+ const jobs = [];
190
+ for (const file of files) {
191
+ try {
192
+ const job = JSON.parse(fs.readFileSync(path.join(this.config.jobsDir, file), 'utf8'));
193
+ if (!filter?.status || job.status === filter.status) {
194
+ jobs.push(job);
195
+ }
196
+ }
197
+ catch {
198
+ // Skip invalid job files
199
+ }
200
+ }
201
+ // Sort by created date, newest first
202
+ return jobs.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
203
+ }
204
+ /**
205
+ * Cancel a running job
206
+ */
207
+ cancel(jobId) {
208
+ const job = this.get(jobId);
209
+ if (!job) {
210
+ return false;
211
+ }
212
+ if (job.status !== 'running') {
213
+ return false;
214
+ }
215
+ // Try to kill the process
216
+ const child = this.activeWorkers.get(jobId);
217
+ if (child && !child.killed) {
218
+ child.kill('SIGTERM');
219
+ }
220
+ else if (job.pid) {
221
+ try {
222
+ process.kill(job.pid, 'SIGTERM');
223
+ }
224
+ catch {
225
+ // Process may already be dead
226
+ }
227
+ }
228
+ job.status = 'cancelled';
229
+ job.completed = new Date().toISOString();
230
+ this.saveJob(job);
231
+ this.activeWorkers.delete(jobId);
232
+ return true;
233
+ }
234
+ /**
235
+ * Clean up old completed/failed jobs
236
+ */
237
+ cleanup(olderThanMs = this.config.jobRetention) {
238
+ const jobs = this.list();
239
+ let cleaned = 0;
240
+ for (const job of jobs) {
241
+ if (job.status === 'running' || job.status === 'pending') {
242
+ continue;
243
+ }
244
+ const completedTime = job.completed ? new Date(job.completed).getTime() : new Date(job.created).getTime();
245
+ if (Date.now() - completedTime > olderThanMs) {
246
+ try {
247
+ fs.unlinkSync(this.getJobPath(job.id));
248
+ cleaned++;
249
+ }
250
+ catch {
251
+ // Ignore deletion errors
252
+ }
253
+ }
254
+ }
255
+ return cleaned;
256
+ }
257
+ /**
258
+ * Get summary of all jobs
259
+ */
260
+ summary() {
261
+ const jobs = this.list();
262
+ return {
263
+ total: jobs.length,
264
+ running: jobs.filter(j => j.status === 'running').length,
265
+ completed: jobs.filter(j => j.status === 'completed').length,
266
+ failed: jobs.filter(j => j.status === 'failed' || j.status === 'timeout').length,
267
+ pending: jobs.filter(j => j.status === 'pending').length
268
+ };
269
+ }
270
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Worker System Types
3
+ */
4
+ export interface WorkerJob {
5
+ id: string;
6
+ task: string;
7
+ workspace: string;
8
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'timeout' | 'cancelled';
9
+ priority: 'low' | 'normal' | 'high' | 'critical';
10
+ created: string;
11
+ started?: string;
12
+ completed?: string;
13
+ result?: string;
14
+ error?: string;
15
+ pid?: number;
16
+ timeout: number;
17
+ exitCode?: number;
18
+ }
19
+ export interface SpawnOptions {
20
+ workspace?: string;
21
+ timeout?: number;
22
+ priority?: 'low' | 'normal' | 'high' | 'critical';
23
+ }
24
+ export interface WorkerConfig {
25
+ maxParallel: number;
26
+ defaultTimeout: number;
27
+ maxTimeout: number;
28
+ jobRetention: number;
29
+ jobsDir: string;
30
+ }
31
+ export declare const DEFAULT_WORKER_CONFIG: WorkerConfig;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Worker System Types
3
+ */
4
+ export const DEFAULT_WORKER_CONFIG = {
5
+ maxParallel: 3,
6
+ defaultTimeout: 300000, // 5 minutes
7
+ maxTimeout: 1800000, // 30 minutes
8
+ jobRetention: 3600000, // 1 hour
9
+ jobsDir: '' // Set at runtime
10
+ };