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.
- package/lib/tunnelClient.js +231 -0
- package/mcp/stdio-wrapper.js +33 -6
- package/package.json +1 -1
|
@@ -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 };
|
package/mcp/stdio-wrapper.js
CHANGED
|
@@ -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
|
-
//
|
|
2191
|
-
|
|
2192
|
-
|
|
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
|