conductor-remote 1.80.0 → 1.82.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/README.md +22 -9
- package/dist/assets/index-CJakNa4V.css +1 -0
- package/dist/assets/index-CZWaZeNN.js +47 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/dev-server.js +184 -62
- package/dist-node/src/mcp-tools.js +21 -1
- package/dist-node/src/plan-usage.js +430 -0
- package/dist-node/src/preview-urls.js +283 -0
- package/dist-node/src/reads.js +19 -12
- package/dist-node/src/routes.js +2 -0
- package/dist-node/src/server.js +22 -6
- package/package.json +1 -1
- package/dist/assets/index-DaafT1eA.css +0 -1
- package/dist/assets/index-ILQ9q-V7.js +0 -47
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
function object(value) {
|
|
6
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
7
|
+
}
|
|
8
|
+
function text(value) {
|
|
9
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
10
|
+
}
|
|
11
|
+
function number(value) {
|
|
12
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
13
|
+
}
|
|
14
|
+
function percent(value) {
|
|
15
|
+
const parsed = number(value);
|
|
16
|
+
return parsed === null ? null : Math.max(0, Math.min(100, parsed));
|
|
17
|
+
}
|
|
18
|
+
function timestamp(value) {
|
|
19
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
20
|
+
// Codex reports seconds; tolerate milliseconds if the protocol changes.
|
|
21
|
+
return value < 10_000_000_000 ? value * 1000 : value;
|
|
22
|
+
}
|
|
23
|
+
if (typeof value !== 'string')
|
|
24
|
+
return null;
|
|
25
|
+
const parsed = Date.parse(value);
|
|
26
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
27
|
+
}
|
|
28
|
+
function codexWindowLabel(duration, slot) {
|
|
29
|
+
if (duration === 300)
|
|
30
|
+
return '5-hour limit';
|
|
31
|
+
if (duration === 1_440)
|
|
32
|
+
return 'Daily limit';
|
|
33
|
+
if (duration === 10_080)
|
|
34
|
+
return 'Weekly limit';
|
|
35
|
+
if (duration && duration % 1_440 === 0)
|
|
36
|
+
return `${duration / 1_440}-day limit`;
|
|
37
|
+
if (duration && duration % 60 === 0)
|
|
38
|
+
return `${duration / 60}-hour limit`;
|
|
39
|
+
return slot === 'primary' ? 'Primary limit' : 'Secondary limit';
|
|
40
|
+
}
|
|
41
|
+
function codexWindow(bucketId, slot, raw) {
|
|
42
|
+
const value = object(raw);
|
|
43
|
+
const usedPercent = percent(value?.usedPercent);
|
|
44
|
+
if (!value || usedPercent === null)
|
|
45
|
+
return null;
|
|
46
|
+
const duration = number(value.windowDurationMins);
|
|
47
|
+
return {
|
|
48
|
+
id: `${bucketId}:${slot}`,
|
|
49
|
+
label: codexWindowLabel(duration, slot),
|
|
50
|
+
usedPercent,
|
|
51
|
+
resetsAt: timestamp(value.resetsAt),
|
|
52
|
+
windowDurationMins: duration
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Reduce Codex's app-server response to the stable, provider-neutral wire shape. */
|
|
56
|
+
export function parseCodexPlanUsage(raw) {
|
|
57
|
+
const envelope = object(raw);
|
|
58
|
+
const payload = object(envelope?.result) ?? envelope;
|
|
59
|
+
const legacy = object(payload?.rateLimits);
|
|
60
|
+
const byLimit = object(payload?.rateLimitsByLimitId);
|
|
61
|
+
const entries = byLimit && Object.keys(byLimit).length ? Object.entries(byLimit) : legacy ? [['codex', legacy]] : [];
|
|
62
|
+
const buckets = [];
|
|
63
|
+
let plan = null;
|
|
64
|
+
for (const [key, candidate] of entries) {
|
|
65
|
+
const snapshot = object(candidate);
|
|
66
|
+
if (!snapshot)
|
|
67
|
+
continue;
|
|
68
|
+
plan ??= text(snapshot.planType);
|
|
69
|
+
const id = text(snapshot.limitId) ?? key;
|
|
70
|
+
const windows = [
|
|
71
|
+
codexWindow(id, 'primary', snapshot.primary),
|
|
72
|
+
codexWindow(id, 'secondary', snapshot.secondary)
|
|
73
|
+
].filter((window) => window !== null);
|
|
74
|
+
if (!windows.length)
|
|
75
|
+
continue;
|
|
76
|
+
buckets.push({ id, label: text(snapshot.limitName) ?? (id === 'codex' ? 'Codex' : id), windows });
|
|
77
|
+
}
|
|
78
|
+
if (!buckets.length) {
|
|
79
|
+
return {
|
|
80
|
+
provider: 'codex',
|
|
81
|
+
label: 'Codex',
|
|
82
|
+
status: 'unavailable',
|
|
83
|
+
plan,
|
|
84
|
+
buckets: [],
|
|
85
|
+
message: 'Codex returned no rolling plan limits for this account.'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
buckets.sort((a, b) => Number(b.id === 'codex') - Number(a.id === 'codex') || a.label.localeCompare(b.label));
|
|
89
|
+
return { provider: 'codex', label: 'Codex', status: 'available', plan, buckets };
|
|
90
|
+
}
|
|
91
|
+
function claudeWindowLabel(limit) {
|
|
92
|
+
const kind = text(limit.kind);
|
|
93
|
+
const model = text(object(object(limit.scope)?.model)?.display_name);
|
|
94
|
+
if (kind === 'session')
|
|
95
|
+
return 'Current session';
|
|
96
|
+
if (kind === 'weekly_all')
|
|
97
|
+
return 'Current week';
|
|
98
|
+
if (kind === 'weekly_scoped' && model)
|
|
99
|
+
return `Current week (${model})`;
|
|
100
|
+
if (model)
|
|
101
|
+
return model;
|
|
102
|
+
return kind?.replaceAll('_', ' ') ?? 'Plan limit';
|
|
103
|
+
}
|
|
104
|
+
function claudeWindow(id, label, raw, active) {
|
|
105
|
+
const value = object(raw);
|
|
106
|
+
const usedPercent = percent(value?.utilization ?? value?.percent);
|
|
107
|
+
if (!value || usedPercent === null)
|
|
108
|
+
return null;
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
label,
|
|
112
|
+
usedPercent,
|
|
113
|
+
resetsAt: timestamp(value.resets_at),
|
|
114
|
+
...(active === undefined ? {} : { active })
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Reduce Claude's experimental `get_usage` control response; tolerate its older named-window shape. */
|
|
118
|
+
export function parseClaudePlanUsage(raw) {
|
|
119
|
+
const envelope = object(raw);
|
|
120
|
+
const control = object(envelope?.response);
|
|
121
|
+
const payload = object(control?.response) ?? envelope;
|
|
122
|
+
const plan = text(payload?.subscription_type);
|
|
123
|
+
if (payload?.rate_limits_available === false) {
|
|
124
|
+
return {
|
|
125
|
+
provider: 'claude',
|
|
126
|
+
label: 'Claude Code',
|
|
127
|
+
status: 'unavailable',
|
|
128
|
+
plan,
|
|
129
|
+
buckets: [],
|
|
130
|
+
message: 'Plan limits are not available for API-key or third-party-provider sessions.'
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const rateLimits = object(payload?.rate_limits);
|
|
134
|
+
const windows = [];
|
|
135
|
+
const limits = Array.isArray(rateLimits?.limits) ? rateLimits.limits : [];
|
|
136
|
+
for (const [index, candidate] of limits.entries()) {
|
|
137
|
+
const limit = object(candidate);
|
|
138
|
+
if (!limit)
|
|
139
|
+
continue;
|
|
140
|
+
const kind = text(limit.kind);
|
|
141
|
+
// These are the rolling plan allowances. Spend/credit records have a different
|
|
142
|
+
// unit and belong in a future money-shaped control rather than a percentage bar.
|
|
143
|
+
if (!kind || !['session', 'weekly_all', 'weekly_scoped'].includes(kind))
|
|
144
|
+
continue;
|
|
145
|
+
const model = text(object(object(limit.scope)?.model)?.display_name);
|
|
146
|
+
const parsed = claudeWindow(`claude:${kind}:${model ?? index}`, claudeWindowLabel(limit), limit, limit.is_active === true);
|
|
147
|
+
if (parsed)
|
|
148
|
+
windows.push(parsed);
|
|
149
|
+
}
|
|
150
|
+
// Claude 2.1 first exposed the same data as named fields. Keep this fallback so an
|
|
151
|
+
// app update that removes the additive `limits` array does not blank the whole card.
|
|
152
|
+
if (!windows.length && rateLimits) {
|
|
153
|
+
const named = [
|
|
154
|
+
['five_hour', 'Current session'],
|
|
155
|
+
['seven_day', 'Current week'],
|
|
156
|
+
['seven_day_opus', 'Current week (Opus)'],
|
|
157
|
+
['seven_day_sonnet', 'Current week (Sonnet)']
|
|
158
|
+
];
|
|
159
|
+
for (const [key, label] of named) {
|
|
160
|
+
const parsed = claudeWindow(`claude:${key}`, label, rateLimits[key]);
|
|
161
|
+
if (parsed)
|
|
162
|
+
windows.push(parsed);
|
|
163
|
+
}
|
|
164
|
+
const scoped = Array.isArray(rateLimits.model_scoped) ? rateLimits.model_scoped : [];
|
|
165
|
+
for (const [index, candidate] of scoped.entries()) {
|
|
166
|
+
const value = object(candidate);
|
|
167
|
+
const model = text(value?.display_name);
|
|
168
|
+
const parsed = claudeWindow(`claude:model:${model ?? index}:${index}`, `Current week (${model ?? 'model'})`, value);
|
|
169
|
+
if (parsed)
|
|
170
|
+
windows.push(parsed);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!windows.length) {
|
|
174
|
+
return {
|
|
175
|
+
provider: 'claude',
|
|
176
|
+
label: 'Claude Code',
|
|
177
|
+
status: 'unavailable',
|
|
178
|
+
plan,
|
|
179
|
+
buckets: [],
|
|
180
|
+
message: 'Claude Code returned no rolling plan limits for this account.'
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
provider: 'claude',
|
|
185
|
+
label: 'Claude Code',
|
|
186
|
+
status: 'available',
|
|
187
|
+
plan,
|
|
188
|
+
buckets: [{ id: 'claude', label: 'Claude Code', windows }]
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const BUNDLED_BINARIES = path.join(os.homedir(), 'Library', 'Application Support', 'com.conductor.app', 'agent-binaries');
|
|
192
|
+
/** Prefer the CLI Conductor runs, falling back to the user's PATH for older app installs. */
|
|
193
|
+
function agentBinary(provider) {
|
|
194
|
+
const root = path.join(BUNDLED_BINARIES, provider);
|
|
195
|
+
try {
|
|
196
|
+
const versions = fs
|
|
197
|
+
.readdirSync(root, { withFileTypes: true })
|
|
198
|
+
.filter(entry => entry.isDirectory())
|
|
199
|
+
.map(entry => entry.name)
|
|
200
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
201
|
+
for (const version of versions) {
|
|
202
|
+
const candidate = path.join(root, version, provider);
|
|
203
|
+
try {
|
|
204
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
205
|
+
return candidate;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// A half-downloaded version is not a CLI; try the next one.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Conductor did not bundle this harness yet; spawn can still resolve the user's CLI.
|
|
214
|
+
}
|
|
215
|
+
return provider;
|
|
216
|
+
}
|
|
217
|
+
const MAX_CLI_OUTPUT = 4 * 1024 * 1024;
|
|
218
|
+
function failureMessage(name, stderr, code) {
|
|
219
|
+
const detail = stderr.trim().slice(-2_000);
|
|
220
|
+
return new Error(`${name} exited before returning usage${code === null ? '' : ` (${code})`}${detail ? `: ${detail}` : ''}`);
|
|
221
|
+
}
|
|
222
|
+
function codexResponse(binary = agentBinary('codex')) {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
const child = spawn(binary, ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
225
|
+
let stdout = '';
|
|
226
|
+
let stderr = '';
|
|
227
|
+
let requested = false;
|
|
228
|
+
let settled = false;
|
|
229
|
+
const finish = (error, result) => {
|
|
230
|
+
if (settled)
|
|
231
|
+
return;
|
|
232
|
+
settled = true;
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
child.kill();
|
|
235
|
+
if (error)
|
|
236
|
+
reject(error);
|
|
237
|
+
else
|
|
238
|
+
resolve(result);
|
|
239
|
+
};
|
|
240
|
+
const timer = setTimeout(() => finish(new Error('Codex plan-usage read timed out')), 8_000);
|
|
241
|
+
child.stdout.setEncoding('utf8');
|
|
242
|
+
child.stderr.setEncoding('utf8');
|
|
243
|
+
child.stderr.on('data', chunk => {
|
|
244
|
+
stderr = (stderr + chunk).slice(-MAX_CLI_OUTPUT);
|
|
245
|
+
});
|
|
246
|
+
child.stdout.on('data', chunk => {
|
|
247
|
+
stdout += chunk;
|
|
248
|
+
if (stdout.length > MAX_CLI_OUTPUT)
|
|
249
|
+
return finish(new Error('Codex plan-usage response was too large'));
|
|
250
|
+
let newline = stdout.indexOf('\n');
|
|
251
|
+
while (newline >= 0) {
|
|
252
|
+
const line = stdout.slice(0, newline).trim();
|
|
253
|
+
stdout = stdout.slice(newline + 1);
|
|
254
|
+
newline = stdout.indexOf('\n');
|
|
255
|
+
if (!line)
|
|
256
|
+
continue;
|
|
257
|
+
let message;
|
|
258
|
+
try {
|
|
259
|
+
message = JSON.parse(line);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (message.id === 1 && !requested) {
|
|
265
|
+
requested = true;
|
|
266
|
+
child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
|
|
267
|
+
child.stdin.write(`${JSON.stringify({ id: 2, method: 'account/rateLimits/read', params: null })}\n`);
|
|
268
|
+
}
|
|
269
|
+
else if (message.id === 2) {
|
|
270
|
+
if (message.error)
|
|
271
|
+
return finish(new Error('Codex rejected the plan-usage request'));
|
|
272
|
+
return finish(null, message);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
child.on('error', error => finish(error));
|
|
277
|
+
child.on('close', code => finish(failureMessage('Codex', stderr, code)));
|
|
278
|
+
child.stdin.on('error', error => finish(error));
|
|
279
|
+
child.stdin.write(`${JSON.stringify({
|
|
280
|
+
id: 1,
|
|
281
|
+
method: 'initialize',
|
|
282
|
+
params: { clientInfo: { name: 'conductor-remote', version: '1' } }
|
|
283
|
+
})}\n`);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function claudeResponse(binary = agentBinary('claude')) {
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const child = spawn(binary, [
|
|
289
|
+
'-p',
|
|
290
|
+
'--input-format',
|
|
291
|
+
'stream-json',
|
|
292
|
+
'--output-format',
|
|
293
|
+
'stream-json',
|
|
294
|
+
'--verbose',
|
|
295
|
+
'--no-session-persistence',
|
|
296
|
+
'--safe-mode'
|
|
297
|
+
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
298
|
+
let stdout = '';
|
|
299
|
+
let stderr = '';
|
|
300
|
+
let settled = false;
|
|
301
|
+
const finish = (error, result) => {
|
|
302
|
+
if (settled)
|
|
303
|
+
return;
|
|
304
|
+
settled = true;
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
child.kill();
|
|
307
|
+
if (error)
|
|
308
|
+
reject(error);
|
|
309
|
+
else
|
|
310
|
+
resolve(result);
|
|
311
|
+
};
|
|
312
|
+
const timer = setTimeout(() => finish(new Error('Claude plan-usage read timed out')), 10_000);
|
|
313
|
+
child.stdout.setEncoding('utf8');
|
|
314
|
+
child.stderr.setEncoding('utf8');
|
|
315
|
+
child.stderr.on('data', chunk => {
|
|
316
|
+
stderr = (stderr + chunk).slice(-MAX_CLI_OUTPUT);
|
|
317
|
+
});
|
|
318
|
+
child.stdout.on('data', chunk => {
|
|
319
|
+
stdout += chunk;
|
|
320
|
+
if (stdout.length > MAX_CLI_OUTPUT)
|
|
321
|
+
return finish(new Error('Claude plan-usage response was too large'));
|
|
322
|
+
let newline = stdout.indexOf('\n');
|
|
323
|
+
while (newline >= 0) {
|
|
324
|
+
const line = stdout.slice(0, newline).trim();
|
|
325
|
+
stdout = stdout.slice(newline + 1);
|
|
326
|
+
newline = stdout.indexOf('\n');
|
|
327
|
+
if (!line)
|
|
328
|
+
continue;
|
|
329
|
+
let message;
|
|
330
|
+
try {
|
|
331
|
+
message = JSON.parse(line);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const response = object(message.response);
|
|
337
|
+
if (message.type !== 'control_response' || response?.request_id !== 'plan-usage')
|
|
338
|
+
continue;
|
|
339
|
+
if (response.subtype !== 'success')
|
|
340
|
+
return finish(new Error('Claude rejected the structured plan-usage request'));
|
|
341
|
+
return finish(null, message);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
child.on('error', error => finish(error));
|
|
345
|
+
child.on('close', code => finish(failureMessage('Claude', stderr, code)));
|
|
346
|
+
child.stdin.on('error', error => finish(error));
|
|
347
|
+
child.stdin.end(`${JSON.stringify({
|
|
348
|
+
type: 'control_request',
|
|
349
|
+
request_id: 'plan-usage',
|
|
350
|
+
request: { subtype: 'get_usage' }
|
|
351
|
+
})}\n`);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
function unavailable(provider, label, message) {
|
|
355
|
+
return { provider, label, status: 'unavailable', plan: null, buckets: [], message };
|
|
356
|
+
}
|
|
357
|
+
function failed(provider, label, error) {
|
|
358
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
359
|
+
console.warn(`[relay] ${label} plan usage failed: ${detail}`);
|
|
360
|
+
const missing = error?.code === 'ENOENT';
|
|
361
|
+
return {
|
|
362
|
+
provider,
|
|
363
|
+
label,
|
|
364
|
+
status: missing ? 'unavailable' : 'error',
|
|
365
|
+
plan: null,
|
|
366
|
+
buckets: [],
|
|
367
|
+
message: missing ? `${label} is not installed on this Mac.` : `Could not read plan usage from ${label}.`
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
export async function readCodexPlanUsage() {
|
|
371
|
+
try {
|
|
372
|
+
return parseCodexPlanUsage(await codexResponse());
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
return failed('codex', 'Codex', error);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
export async function readClaudePlanUsage() {
|
|
379
|
+
try {
|
|
380
|
+
return parseClaudePlanUsage(await claudeResponse());
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
return failed('claude', 'Claude Code', error);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const DEFAULT_READERS = [
|
|
387
|
+
{ provider: 'claude', label: 'Claude Code', read: readClaudePlanUsage },
|
|
388
|
+
{ provider: 'codex', label: 'Codex', read: readCodexPlanUsage }
|
|
389
|
+
];
|
|
390
|
+
const UNSUPPORTED = [
|
|
391
|
+
unavailable('cursor', 'Cursor Agent', 'Cursor Agent does not expose plan limits through its CLI.'),
|
|
392
|
+
unavailable('opencode', 'OpenCode', 'OpenCode reports local token and cost totals, not provider plan limits.')
|
|
393
|
+
];
|
|
394
|
+
/** Coalesced provider reads. `/api/usage` may be opened from several phones at once. */
|
|
395
|
+
export class PlanUsageService {
|
|
396
|
+
readers;
|
|
397
|
+
cacheMs;
|
|
398
|
+
now;
|
|
399
|
+
cached = null;
|
|
400
|
+
inFlight = null;
|
|
401
|
+
constructor(options = {}) {
|
|
402
|
+
this.readers = options.readers ?? DEFAULT_READERS;
|
|
403
|
+
this.cacheMs = options.cacheMs ?? 60_000;
|
|
404
|
+
this.now = options.now ?? Date.now;
|
|
405
|
+
}
|
|
406
|
+
read(force = false) {
|
|
407
|
+
if (!force && this.cached && this.now() - this.cached.fetchedAt < this.cacheMs)
|
|
408
|
+
return Promise.resolve(this.cached);
|
|
409
|
+
if (this.inFlight)
|
|
410
|
+
return this.inFlight;
|
|
411
|
+
const pending = Promise.all(this.readers.map(async (reader) => {
|
|
412
|
+
try {
|
|
413
|
+
return await reader.read();
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
return failed(reader.provider, reader.label, error);
|
|
417
|
+
}
|
|
418
|
+
})).then(providers => {
|
|
419
|
+
const snapshot = { providers: [...providers, ...UNSUPPORTED], fetchedAt: this.now() };
|
|
420
|
+
this.cached = snapshot;
|
|
421
|
+
return snapshot;
|
|
422
|
+
});
|
|
423
|
+
this.inFlight = pending;
|
|
424
|
+
void pending.finally(() => {
|
|
425
|
+
if (this.inFlight === pending)
|
|
426
|
+
this.inFlight = null;
|
|
427
|
+
});
|
|
428
|
+
return pending;
|
|
429
|
+
}
|
|
430
|
+
}
|