polydev-ai 1.9.35 → 1.9.37

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,231 @@
1
+ /**
2
+ * TunnelClient — CLI-as-API client for the local MCP process.
3
+ *
4
+ * Polls the Polydev server for queued API requests, routes them to
5
+ * local CLI tools via cliManager, and posts responses back.
6
+ *
7
+ * Lifecycle:
8
+ * 1. start() → begins heartbeat (30s) + polling (3s)
9
+ * 2. pollForRequests() → GET /api/tunnel/pending
10
+ * 3. handleRequest() → cliManager.sendCliPrompt() → POST /api/tunnel/respond
11
+ * 4. stop() → clears intervals
12
+ */
13
+
14
+ class TunnelClient {
15
+ constructor(serverBaseUrl, authToken, cliManager) {
16
+ this.serverBaseUrl = serverBaseUrl.replace(/\/api\/mcp$/, ''); // strip /api/mcp if present
17
+ this.authToken = authToken;
18
+ this.cliManager = cliManager;
19
+
20
+ this.heartbeatInterval = null;
21
+ this.pollInterval = null;
22
+ this._processing = new Set(); // track in-flight request IDs
23
+ this._started = false;
24
+
25
+ // Configurable intervals
26
+ this.HEARTBEAT_INTERVAL_MS = 30_000; // 30s
27
+ this.POLL_INTERVAL_MS = 3_000; // 3s
28
+ this.CLI_TIMEOUT_MS = 120_000; // 2 min per request
29
+ }
30
+
31
+ /**
32
+ * Start the tunnel client (heartbeat + polling)
33
+ */
34
+ async start() {
35
+ if (this._started) return;
36
+ this._started = true;
37
+
38
+ console.error('[Tunnel] Starting CLI-as-API tunnel client');
39
+
40
+ // Send initial heartbeat immediately
41
+ try {
42
+ await this.sendHeartbeat();
43
+ } catch (err) {
44
+ console.error('[Tunnel] Initial heartbeat failed:', err.message);
45
+ }
46
+
47
+ // Start periodic heartbeat
48
+ this.heartbeatInterval = setInterval(() => {
49
+ this.sendHeartbeat().catch(err => {
50
+ console.error('[Tunnel] Heartbeat error:', err.message);
51
+ });
52
+ }, this.HEARTBEAT_INTERVAL_MS);
53
+
54
+ // Start polling for requests
55
+ this.pollInterval = setInterval(() => {
56
+ this.pollForRequests().catch(err => {
57
+ console.error('[Tunnel] Poll error:', err.message);
58
+ });
59
+ }, this.POLL_INTERVAL_MS);
60
+
61
+ console.error('[Tunnel] Tunnel client started (heartbeat: 30s, poll: 3s)');
62
+ }
63
+
64
+ /**
65
+ * Stop the tunnel client
66
+ */
67
+ stop() {
68
+ if (this.heartbeatInterval) {
69
+ clearInterval(this.heartbeatInterval);
70
+ this.heartbeatInterval = null;
71
+ }
72
+ if (this.pollInterval) {
73
+ clearInterval(this.pollInterval);
74
+ this.pollInterval = null;
75
+ }
76
+ this._started = false;
77
+ console.error('[Tunnel] Tunnel client stopped');
78
+ }
79
+
80
+ /**
81
+ * Send heartbeat with available CLI providers
82
+ */
83
+ async sendHeartbeat() {
84
+ const status = await this.cliManager.getCliStatus();
85
+ const providers = Object.entries(status)
86
+ .filter(([_, s]) => s.available && s.authenticated)
87
+ .map(([id]) => id);
88
+
89
+ const packageVersion = this._getPackageVersion();
90
+
91
+ const url = `${this.serverBaseUrl}/api/tunnel/heartbeat`;
92
+ const res = await fetch(url, {
93
+ method: 'POST',
94
+ headers: {
95
+ 'Authorization': `Bearer ${this.authToken}`,
96
+ 'Content-Type': 'application/json',
97
+ },
98
+ body: JSON.stringify({
99
+ available_providers: providers,
100
+ client_version: packageVersion,
101
+ }),
102
+ });
103
+
104
+ if (!res.ok) {
105
+ const text = await res.text().catch(() => '');
106
+ throw new Error(`Heartbeat failed (${res.status}): ${text}`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Poll for pending tunnel requests
112
+ */
113
+ async pollForRequests() {
114
+ const url = `${this.serverBaseUrl}/api/tunnel/pending`;
115
+ const res = await fetch(url, {
116
+ method: 'GET',
117
+ headers: {
118
+ 'Authorization': `Bearer ${this.authToken}`,
119
+ },
120
+ });
121
+
122
+ if (!res.ok) {
123
+ // 401 is expected if token expired — don't spam logs
124
+ if (res.status === 401) return;
125
+ const text = await res.text().catch(() => '');
126
+ throw new Error(`Poll failed (${res.status}): ${text}`);
127
+ }
128
+
129
+ const data = await res.json();
130
+ const requests = data.requests || [];
131
+
132
+ for (const req of requests) {
133
+ // Skip if already processing
134
+ if (this._processing.has(req.id)) continue;
135
+ this._processing.add(req.id);
136
+
137
+ // Handle concurrently (don't await)
138
+ this.handleRequest(req).catch(err => {
139
+ console.error(`[Tunnel] Request ${req.id} error:`, err.message);
140
+ }).finally(() => {
141
+ this._processing.delete(req.id);
142
+ });
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Handle a single tunnel request by routing to CLI
148
+ */
149
+ async handleRequest(request) {
150
+ const startTime = Date.now();
151
+ console.error(`[Tunnel] Processing request ${request.id} → ${request.provider}`);
152
+
153
+ try {
154
+ const result = await this.cliManager.sendCliPrompt(
155
+ request.provider,
156
+ request.prompt,
157
+ 'args',
158
+ this.CLI_TIMEOUT_MS,
159
+ request.model_requested || null
160
+ );
161
+
162
+ const latencyMs = Date.now() - startTime;
163
+
164
+ if (result.success) {
165
+ console.error(`[Tunnel] Request ${request.id} completed (${latencyMs}ms)`);
166
+ await this.sendResponse({
167
+ request_id: request.id,
168
+ content: result.content || '',
169
+ model_used: result.model || result.detectedModel || request.provider,
170
+ tokens_used: result.tokens_used || null,
171
+ latency_ms: latencyMs,
172
+ });
173
+ } else {
174
+ console.error(`[Tunnel] Request ${request.id} CLI error: ${result.error}`);
175
+ await this.sendResponse({
176
+ request_id: request.id,
177
+ error: result.error || 'CLI execution failed',
178
+ latency_ms: latencyMs,
179
+ });
180
+ }
181
+ } catch (err) {
182
+ const latencyMs = Date.now() - startTime;
183
+ console.error(`[Tunnel] Request ${request.id} exception: ${err.message}`);
184
+ await this.sendResponse({
185
+ request_id: request.id,
186
+ error: err.message || 'Unexpected error',
187
+ latency_ms: latencyMs,
188
+ });
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Send response back to server
194
+ */
195
+ async sendResponse(responseData) {
196
+ const url = `${this.serverBaseUrl}/api/tunnel/respond`;
197
+ const res = await fetch(url, {
198
+ method: 'POST',
199
+ headers: {
200
+ 'Authorization': `Bearer ${this.authToken}`,
201
+ 'Content-Type': 'application/json',
202
+ },
203
+ body: JSON.stringify(responseData),
204
+ });
205
+
206
+ if (!res.ok) {
207
+ const text = await res.text().catch(() => '');
208
+ console.error(`[Tunnel] Failed to submit response for ${responseData.request_id}: ${res.status} ${text}`);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Get package version from package.json
214
+ */
215
+ _getPackageVersion() {
216
+ try {
217
+ const path = require('path');
218
+ const fs = require('fs');
219
+ const pkgPath = path.join(__dirname, '..', 'package.json');
220
+ if (fs.existsSync(pkgPath)) {
221
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
222
+ return pkg.version || 'unknown';
223
+ }
224
+ } catch {
225
+ // ignore
226
+ }
227
+ return 'unknown';
228
+ }
229
+ }
230
+
231
+ module.exports = { TunnelClient };
@@ -49,6 +49,7 @@ const fs = require('fs');
49
49
  const path = require('path');
50
50
  const os = require('os');
51
51
  const { CLIManager } = require('../lib/cliManager');
52
+ const { TunnelClient } = require('../lib/tunnelClient');
52
53
 
53
54
  // MCP stdio servers must only emit JSON-RPC on stdout.
54
55
  // Redirect any accidental console output to stderr to avoid handshake failures.
@@ -188,8 +189,7 @@ function cleanCliResponse(content) {
188
189
  // State: metadata - skip until we hit a section marker
189
190
  if (state === 'metadata') {
190
191
  // Skip known metadata patterns
191
- if (!trimmed) continue;
192
- if (trimmed.startsWith('provider:')) continue;
192
+ if (!trimmed || trimmed.startsWith('provider:')) continue;
193
193
  if (trimmed.startsWith('approval:')) continue;
194
194
  if (trimmed.startsWith('sandbox:')) continue;
195
195
  if (trimmed.startsWith('reasoning effort:')) continue;
@@ -355,6 +355,9 @@ class StdioMCPWrapper {
355
355
 
356
356
  // Login server reference (for cleanup)
357
357
  this._loginServer = null;
358
+
359
+ // CLI-as-API tunnel client (started after CLI detection)
360
+ this.tunnelClient = null;
358
361
  }
359
362
 
360
363
  loadManifest() {
@@ -2187,9 +2190,10 @@ To re-login: /polydev:login`
2187
2190
  }
2188
2191
  });
2189
2192
 
2190
- // Fast-collect: resolve early once we have maxPerspectives successes OR all complete
2191
- localResults = await this.collectFirstNSuccesses(cliPromises, maxPerspectives);
2192
- console.error(`[Stdio Wrapper] Fast-collect: got ${localResults.filter(r => r.success).length} successful, ${localResults.filter(r => !r.success).length} failed out of ${cliPromises.length} CLIs`);
2193
+ // Wait for ALL CLIs they're free, so always collect all of them
2194
+ // If 3 CLIs available, wait for all 3. If 2, wait for both. Credits fill remaining slots.
2195
+ localResults = await this.collectFirstNSuccesses(cliPromises, cliPromises.length);
2196
+ console.error(`[Stdio Wrapper] CLI collect: got ${localResults.filter(r => r.success).length} successful, ${localResults.filter(r => !r.success).length} failed out of ${cliPromises.length} CLIs`);
2193
2197
  this.sendProgressNotification(progressToken, 55, 100, `Got ${localResults.filter(r => r.success).length} CLI responses`);
2194
2198
  }
2195
2199
 
@@ -2408,8 +2412,8 @@ To re-login: /polydev:login`
2408
2412
  const response = await fetch(`${this.serverUrl}/report-cli-results`, {
2409
2413
  method: 'POST',
2410
2414
  headers: {
2411
- 'Authorization': `Bearer ${this.userToken}`,
2412
2415
  'Content-Type': 'application/json',
2416
+ 'Authorization': `Bearer ${this.userToken}`,
2413
2417
  'User-Agent': 'polydev-stdio-wrapper/1.0.0'
2414
2418
  },
2415
2419
  body: JSON.stringify(reportPayload)
@@ -2949,6 +2953,27 @@ To re-login: /polydev:login`
2949
2953
  }
2950
2954
  }
2951
2955
 
2956
+ /**
2957
+ * Start CLI-as-API tunnel client if authenticated.
2958
+ * Runs in background — polls for external API requests and routes them to local CLIs.
2959
+ */
2960
+ startTunnelClient() {
2961
+ if (!this.userToken) {
2962
+ return; // No auth, skip tunnel
2963
+ }
2964
+
2965
+ try {
2966
+ this.tunnelClient = new TunnelClient(
2967
+ this.serverUrl, // https://www.polydev.ai/api/mcp
2968
+ this.userToken,
2969
+ this.cliManager
2970
+ );
2971
+ this.tunnelClient.start();
2972
+ } catch (err) {
2973
+ console.error('[Tunnel] Failed to start tunnel client:', err.message);
2974
+ }
2975
+ }
2976
+
2952
2977
  /**
2953
2978
  * Record local usage for analytics
2954
2979
  */
@@ -3222,6 +3247,8 @@ To re-login: /polydev:login`
3222
3247
  this._cliDetectionResolver();
3223
3248
  }
3224
3249
  this.startSmartRefreshScheduler();
3250
+ // Start CLI-as-API tunnel client (background, non-blocking)
3251
+ this.startTunnelClient();
3225
3252
  });
3226
3253
  } else {
3227
3254
  // No token - CLI detection will run after login completes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polydev-ai",
3
- "version": "1.9.35",
3
+ "version": "1.9.37",
4
4
  "engines": {
5
5
  "node": ">=20.x <=22.x"
6
6
  },