polydev-ai 1.9.36 → 1.9.38

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() {
@@ -2950,6 +2953,27 @@ To re-login: /polydev:login`
2950
2953
  }
2951
2954
  }
2952
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
+
2953
2977
  /**
2954
2978
  * Record local usage for analytics
2955
2979
  */
@@ -3223,6 +3247,8 @@ To re-login: /polydev:login`
3223
3247
  this._cliDetectionResolver();
3224
3248
  }
3225
3249
  this.startSmartRefreshScheduler();
3250
+ // Start CLI-as-API tunnel client (background, non-blocking)
3251
+ this.startTunnelClient();
3226
3252
  });
3227
3253
  } else {
3228
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.36",
3
+ "version": "1.9.38",
4
4
  "engines": {
5
5
  "node": ">=20.x <=22.x"
6
6
  },