@gakim-digital/dexter-bridge 0.12.1 → 0.13.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/package.json +1 -1
- package/src/agent.js +144 -40
- package/src/agentOutput.js +6 -0
- package/src/api.js +75 -0
- package/src/cli.js +160 -110
- package/src/config.js +20 -0
- package/src/framerMcpClient.js +8 -8
- package/src/framerTools.js +151 -19
- package/src/protocol.js +63 -0
- package/src/providers/codexAppServer.js +50 -34
- package/src/taskEvidence.js +245 -0
- package/src/webflowTools.js +1045 -0
package/src/cli.js
CHANGED
|
@@ -6,17 +6,27 @@ import {
|
|
|
6
6
|
BRIDGE_BUILD_FINGERPRINT,
|
|
7
7
|
BRIDGE_CAPABILITIES,
|
|
8
8
|
BRIDGE_VERSION,
|
|
9
|
+
DEXTER_HOSTS,
|
|
10
|
+
bridgeDisplayName,
|
|
9
11
|
clearConfig,
|
|
10
12
|
defaultConfigDir,
|
|
13
|
+
dexterHostConfigDir,
|
|
11
14
|
normalizeBridgeProduct,
|
|
12
15
|
normalizeAgentName,
|
|
16
|
+
normalizeDexterHost,
|
|
13
17
|
readConfig,
|
|
14
18
|
resolveAgentName,
|
|
15
19
|
resolveApiBaseUrl,
|
|
16
20
|
resolveCompanionModelName,
|
|
17
21
|
saveConfigPatch,
|
|
18
22
|
} from './config.js';
|
|
19
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
checkAgentAvailability,
|
|
25
|
+
checkAllAgents,
|
|
26
|
+
executeRun,
|
|
27
|
+
processEnvWithCliPath,
|
|
28
|
+
stopAgentProcesses,
|
|
29
|
+
} from './agent.js';
|
|
20
30
|
import { setUpCursorCli } from './providers/cursorAcp.js';
|
|
21
31
|
import {
|
|
22
32
|
claimPairing,
|
|
@@ -231,8 +241,10 @@ async function executeClaimedRun({
|
|
|
231
241
|
preventIdleSleep = false,
|
|
232
242
|
}) {
|
|
233
243
|
const awakeGuard = preventIdleSleep ? startRunAwakeGuard() : null;
|
|
244
|
+
const running = Promise.resolve().then(() => execute(run, executeOptions));
|
|
245
|
+
activeRuns.add(running);
|
|
234
246
|
try {
|
|
235
|
-
await
|
|
247
|
+
await running;
|
|
236
248
|
return true;
|
|
237
249
|
} catch (error) {
|
|
238
250
|
logger?.error?.('run_execution_failed', {
|
|
@@ -244,6 +256,7 @@ async function executeClaimedRun({
|
|
|
244
256
|
console.error(`${productName} run ${run?.runId || 'unknown'} failed; continuing to poll.`);
|
|
245
257
|
return false;
|
|
246
258
|
} finally {
|
|
259
|
+
activeRuns.delete(running);
|
|
247
260
|
awakeGuard?.close();
|
|
248
261
|
}
|
|
249
262
|
}
|
|
@@ -252,6 +265,28 @@ function wait(delayMs) {
|
|
|
252
265
|
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
253
266
|
}
|
|
254
267
|
|
|
268
|
+
const activeRuns = new Set();
|
|
269
|
+
const SHUTDOWN_GRACE_MS = 5_000;
|
|
270
|
+
let signalsHandled = false;
|
|
271
|
+
|
|
272
|
+
// Stopping the bridge stops its agents and lets their runs report the
|
|
273
|
+
// cancellation before the process exits. A second signal exits at once.
|
|
274
|
+
function stopRunsOnSignals() {
|
|
275
|
+
if (signalsHandled) return;
|
|
276
|
+
signalsHandled = true;
|
|
277
|
+
let stopping = false;
|
|
278
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
279
|
+
process.on(signal, () => {
|
|
280
|
+
const exitCode = 128 + os.constants.signals[signal];
|
|
281
|
+
if (stopping) process.exit(exitCode);
|
|
282
|
+
stopping = true;
|
|
283
|
+
stopAgentProcesses();
|
|
284
|
+
void Promise.race([Promise.allSettled([...activeRuns]), wait(SHUTDOWN_GRACE_MS)])
|
|
285
|
+
.then(() => process.exit(exitCode));
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
255
290
|
function isInvalidPairingError(error) {
|
|
256
291
|
return isCompanionPairingInvalidError(error);
|
|
257
292
|
}
|
|
@@ -280,7 +315,8 @@ function runCommand(command, args) {
|
|
|
280
315
|
|
|
281
316
|
// Upgrades the Homebrew install at most once an hour. On success the process
|
|
282
317
|
// exits non-zero so launchd restarts the service on the new version.
|
|
283
|
-
async function selfUpdateWithHomebrew(
|
|
318
|
+
async function selfUpdateWithHomebrew(logger) {
|
|
319
|
+
const configDir = defaultConfigDir();
|
|
284
320
|
const lastAttempt = Date.parse(readConfig(configDir).lastSelfUpdateAt || '');
|
|
285
321
|
if (Number.isFinite(lastAttempt) && Date.now() - lastAttempt < SELF_UPDATE_INTERVAL_MS) return;
|
|
286
322
|
saveConfigPatch({ lastSelfUpdateAt: new Date().toISOString() }, configDir);
|
|
@@ -293,18 +329,23 @@ async function selfUpdateWithHomebrew(configDir, logger) {
|
|
|
293
329
|
throw restart;
|
|
294
330
|
}
|
|
295
331
|
|
|
296
|
-
|
|
332
|
+
function productConfigDirs(rootDir) {
|
|
333
|
+
return DEXTER_HOSTS.map((host) => dexterHostConfigDir(rootDir, host));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Resolves with the first folder that holds a pairing.
|
|
337
|
+
async function waitForPairing(configDirs) {
|
|
297
338
|
console.log('Waiting for a new pairing from Dexter...');
|
|
298
339
|
while (true) {
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
340
|
+
const paired = configDirs.find((dir) => readConfig(dir).deviceToken);
|
|
341
|
+
if (paired) return paired;
|
|
301
342
|
await wait(PAIRING_WAIT_POLL_MS);
|
|
302
343
|
}
|
|
303
344
|
}
|
|
304
345
|
|
|
305
346
|
function clearInvalidPairing(configDir, cause, rejectedToken) {
|
|
306
347
|
const current = readConfig(configDir);
|
|
307
|
-
const
|
|
348
|
+
const name = bridgeDisplayName(current);
|
|
308
349
|
// Another bridge command may have just saved a fresh pairing; keep it.
|
|
309
350
|
if (!rejectedToken || current.deviceToken === rejectedToken) {
|
|
310
351
|
saveConfigPatch({
|
|
@@ -314,7 +355,7 @@ function clearInvalidPairing(configDir, cause, rejectedToken) {
|
|
|
314
355
|
}, configDir);
|
|
315
356
|
}
|
|
316
357
|
const error = new Error(
|
|
317
|
-
`This ${
|
|
358
|
+
`This ${name} Bridge pairing was disconnected. Reopen ${name} and run the new pairing command.`,
|
|
318
359
|
{ cause },
|
|
319
360
|
);
|
|
320
361
|
error.code = 'FRAMER_COMPANION_NOT_PAIRED';
|
|
@@ -550,27 +591,30 @@ async function pairCommand({ apiBaseUrl, args, flags, configDir }) {
|
|
|
550
591
|
runtimeProfiles: availability.runtimeProfiles.map(publicRuntimeProfile),
|
|
551
592
|
runtimeSelection,
|
|
552
593
|
});
|
|
594
|
+
const product = normalizeBridgeProduct(result.product);
|
|
595
|
+
const dexterHost = product.id === 'dexter' ? normalizeDexterHost(result.product?.host) : null;
|
|
596
|
+
const pairedDir = dexterHost ? dexterHostConfigDir(configDir, dexterHost) : configDir;
|
|
553
597
|
saveConfigPatch({
|
|
554
598
|
apiBaseUrl,
|
|
555
599
|
agent,
|
|
556
600
|
model,
|
|
557
601
|
runtimeSelection,
|
|
558
|
-
product
|
|
602
|
+
product,
|
|
603
|
+
dexterHost,
|
|
559
604
|
deviceToken: result.deviceToken,
|
|
560
605
|
device: result.device,
|
|
561
606
|
agentCommands: availability.agentCommands,
|
|
562
607
|
pairedAt: new Date().toISOString(),
|
|
563
|
-
},
|
|
564
|
-
|
|
565
|
-
console.log(`Paired ${product.name} Bridge: ${result.device?.name || 'device'}`);
|
|
608
|
+
}, pairedDir);
|
|
609
|
+
console.log(`Paired ${bridgeDisplayName({ product, dexterHost })} Bridge: ${result.device?.name || 'device'}`);
|
|
566
610
|
if (runtimeSelection) {
|
|
567
611
|
console.log(`Runtime: ${runtimeProfile?.label || runtimeLabel(runtimeSelection)}`);
|
|
568
612
|
}
|
|
569
613
|
console.log(`API: ${apiBaseUrl}`);
|
|
614
|
+
return pairedDir;
|
|
570
615
|
}
|
|
571
616
|
|
|
572
617
|
async function statusCommand({ apiBaseUrl, config, configDir }) {
|
|
573
|
-
const product = normalizeBridgeProduct(config.product);
|
|
574
618
|
const deviceToken = requireDeviceToken(config);
|
|
575
619
|
const runtimeSelection = requestedRuntime({}, config);
|
|
576
620
|
const agent = runtimeSelection
|
|
@@ -598,8 +642,8 @@ async function statusCommand({ apiBaseUrl, config, configDir }) {
|
|
|
598
642
|
if (isInvalidPairingError(error)) throw clearInvalidPairing(configDir, error);
|
|
599
643
|
throw error;
|
|
600
644
|
}
|
|
601
|
-
console.log(
|
|
602
|
-
console.log(`Device: ${result.device?.name || `${
|
|
645
|
+
console.log(`${bridgeDisplayName(config)}: ${result.device?.online ? 'online' : 'paired'}`);
|
|
646
|
+
console.log(`Device: ${result.device?.name || `${bridgeDisplayName(config)} Bridge`}`);
|
|
603
647
|
if (runtimeSelection) {
|
|
604
648
|
console.log(`Runtime: ${runtimeProfile?.label || runtimeLabel(runtimeSelection)}`);
|
|
605
649
|
}
|
|
@@ -613,20 +657,20 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
|
|
|
613
657
|
if (!deviceToken) {
|
|
614
658
|
// First run: pair interactively so `npx @gakim-digital/dexter-bridge` alone is
|
|
615
659
|
if (runsAsBackgroundService()) {
|
|
616
|
-
|
|
617
|
-
activeConfig = readConfig(configDir);
|
|
660
|
+
configDir = await waitForPairing(productConfigDirs(configDir));
|
|
618
661
|
} else {
|
|
619
662
|
if (!process.stdin.isTTY) requireDeviceToken(config);
|
|
620
663
|
console.log('This local agent bridge is not paired yet.');
|
|
621
664
|
console.log('Open the product connection screen and generate a new bridge command.');
|
|
622
665
|
const code = await promptForPairingCode();
|
|
623
666
|
if (!code) requireDeviceToken(config);
|
|
624
|
-
await pairCommand({ apiBaseUrl, args: [code], flags, configDir });
|
|
625
|
-
activeConfig = readConfig(configDir);
|
|
626
|
-
deviceToken = activeConfig.deviceToken;
|
|
667
|
+
configDir = await pairCommand({ apiBaseUrl, args: [code], flags, configDir });
|
|
627
668
|
}
|
|
669
|
+
activeConfig = readConfig(configDir);
|
|
670
|
+
deviceToken = activeConfig.deviceToken;
|
|
628
671
|
}
|
|
629
672
|
const product = normalizeBridgeProduct(activeConfig.product);
|
|
673
|
+
const label = bridgeDisplayName(activeConfig);
|
|
630
674
|
const runtimeSelection = requestedRuntime(flags, activeConfig);
|
|
631
675
|
if (product.connectionScope === 'app-builder' && !runtimeSelection) {
|
|
632
676
|
const error = new Error('This App Builder connection requires --runtime codex, --runtime claude-code, or --runtime opencode.');
|
|
@@ -673,8 +717,56 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
|
|
|
673
717
|
};
|
|
674
718
|
let pollFailureCount = 0;
|
|
675
719
|
let nextCatalogRefreshAt = Date.now() + MODEL_CATALOG_REFRESH_MS;
|
|
720
|
+
let catalogRefresh = null;
|
|
721
|
+
// Agent probes can take tens of seconds, so the catalog refreshes in the
|
|
722
|
+
// background and never delays a queued run.
|
|
723
|
+
const refreshModelCatalog = (requestedAt) => {
|
|
724
|
+
catalogRefresh ||= (async () => {
|
|
725
|
+
try {
|
|
726
|
+
const refreshedAvailability = await inspectAvailability(activeConfig, runtimeSelection, {
|
|
727
|
+
forceModelRefresh: true,
|
|
728
|
+
previousRuntimeProfiles: availability.runtimeProfiles,
|
|
729
|
+
});
|
|
730
|
+
const refreshedRuntimeProfile = runtimeSelection
|
|
731
|
+
? requireAvailableRuntime(refreshedAvailability, runtimeSelection)
|
|
732
|
+
: null;
|
|
733
|
+
const refreshedCheck = refreshedRuntimeProfile
|
|
734
|
+
? null
|
|
735
|
+
: requireAvailableAgent(refreshedAvailability, agent);
|
|
736
|
+
availability = refreshedAvailability;
|
|
737
|
+
runtimeProfile = refreshedRuntimeProfile;
|
|
738
|
+
check = refreshedCheck;
|
|
739
|
+
model = runtimeProfile
|
|
740
|
+
? runtimeProfile.models.find((candidate) => candidate.id === requestedModel)?.id
|
|
741
|
+
|| runtimeProfile.models[0].id
|
|
742
|
+
: availableModel(check, requestedModel);
|
|
743
|
+
metadata = availability.metadata;
|
|
744
|
+
profiles = runtimeProfileMap(availability.runtimeProfiles);
|
|
745
|
+
await heartbeat(apiBaseUrl, {
|
|
746
|
+
deviceToken,
|
|
747
|
+
status: 'ready',
|
|
748
|
+
agent,
|
|
749
|
+
model,
|
|
750
|
+
metadata,
|
|
751
|
+
runtimeProfiles: availability.runtimeProfiles.map(publicRuntimeProfile),
|
|
752
|
+
allowInsecureHttp: flags['allow-insecure-http'],
|
|
753
|
+
});
|
|
754
|
+
pollLogger.info(requestedAt ? 'model_catalog_refresh_completed' : 'model_catalog_refreshed', {
|
|
755
|
+
...(requestedAt ? { requestedAt } : {}),
|
|
756
|
+
models: availability.runtimeProfiles.flatMap((profile) =>
|
|
757
|
+
profile.models.map((candidate) => candidate.id)),
|
|
758
|
+
});
|
|
759
|
+
} catch (error) {
|
|
760
|
+
pollLogger.warn('model_catalog_refresh_failed', { error: errorMeta(error) });
|
|
761
|
+
} finally {
|
|
762
|
+
nextCatalogRefreshAt = Date.now() + MODEL_CATALOG_REFRESH_MS;
|
|
763
|
+
catalogRefresh = null;
|
|
764
|
+
}
|
|
765
|
+
})();
|
|
766
|
+
return catalogRefresh;
|
|
767
|
+
};
|
|
676
768
|
|
|
677
|
-
console.log(`${
|
|
769
|
+
console.log(`${label} Bridge connected to ${apiBaseUrl}`);
|
|
678
770
|
console.log(
|
|
679
771
|
`${runtimeSelection ? 'Runtime' : 'Agent'}: ${
|
|
680
772
|
runtimeSelection ? runtimeProfile?.label || runtimeLabel(runtimeSelection) : agent
|
|
@@ -686,37 +778,7 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
|
|
|
686
778
|
|
|
687
779
|
try {
|
|
688
780
|
while (true) {
|
|
689
|
-
if (Date.now() >= nextCatalogRefreshAt)
|
|
690
|
-
try {
|
|
691
|
-
const refreshedAvailability = await inspectAvailability(activeConfig, runtimeSelection, {
|
|
692
|
-
forceModelRefresh: true,
|
|
693
|
-
previousRuntimeProfiles: availability.runtimeProfiles,
|
|
694
|
-
});
|
|
695
|
-
const refreshedRuntimeProfile = runtimeSelection
|
|
696
|
-
? requireAvailableRuntime(refreshedAvailability, runtimeSelection)
|
|
697
|
-
: null;
|
|
698
|
-
const refreshedCheck = refreshedRuntimeProfile
|
|
699
|
-
? null
|
|
700
|
-
: requireAvailableAgent(refreshedAvailability, agent);
|
|
701
|
-
availability = refreshedAvailability;
|
|
702
|
-
runtimeProfile = refreshedRuntimeProfile;
|
|
703
|
-
check = refreshedCheck;
|
|
704
|
-
model = runtimeProfile
|
|
705
|
-
? runtimeProfile.models.find((candidate) => candidate.id === requestedModel)?.id
|
|
706
|
-
|| runtimeProfile.models[0].id
|
|
707
|
-
: availableModel(check, requestedModel);
|
|
708
|
-
metadata = availability.metadata;
|
|
709
|
-
profiles = runtimeProfileMap(availability.runtimeProfiles);
|
|
710
|
-
pollLogger.info('model_catalog_refreshed', {
|
|
711
|
-
models: availability.runtimeProfiles.flatMap((profile) =>
|
|
712
|
-
profile.models.map((candidate) => candidate.id)),
|
|
713
|
-
});
|
|
714
|
-
} catch (error) {
|
|
715
|
-
pollLogger.warn('model_catalog_refresh_failed', { error: errorMeta(error) });
|
|
716
|
-
} finally {
|
|
717
|
-
nextCatalogRefreshAt = Date.now() + MODEL_CATALOG_REFRESH_MS;
|
|
718
|
-
}
|
|
719
|
-
}
|
|
781
|
+
if (Date.now() >= nextCatalogRefreshAt) void refreshModelCatalog();
|
|
720
782
|
let poll;
|
|
721
783
|
try {
|
|
722
784
|
poll = await pollRun(apiBaseUrl, {
|
|
@@ -726,6 +788,7 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
|
|
|
726
788
|
model,
|
|
727
789
|
metadata,
|
|
728
790
|
runtimeProfiles: availability.runtimeProfiles.map(publicRuntimeProfile),
|
|
791
|
+
refreshingModelCatalog: Boolean(catalogRefresh),
|
|
729
792
|
});
|
|
730
793
|
pollFailureCount = 0;
|
|
731
794
|
} catch (error) {
|
|
@@ -744,64 +807,29 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
|
|
|
744
807
|
const disconnected = clearInvalidPairing(configDir, error, deviceToken);
|
|
745
808
|
console.log(disconnected.message);
|
|
746
809
|
if (!runsAsBackgroundService()) return;
|
|
747
|
-
|
|
810
|
+
await waitForPairing([configDir]);
|
|
811
|
+
deviceToken = readConfig(configDir).deviceToken;
|
|
748
812
|
pollFailureCount = 0;
|
|
749
813
|
continue;
|
|
750
814
|
}
|
|
751
815
|
if (isUpdateRequiredError(error) && runsAsBackgroundService()) {
|
|
752
|
-
await selfUpdateWithHomebrew(
|
|
816
|
+
await selfUpdateWithHomebrew(pollLogger);
|
|
753
817
|
}
|
|
754
818
|
if (once) throw error;
|
|
755
819
|
pollFailureCount += 1;
|
|
756
820
|
const retryInMs = pollBackoffMs(pollFailureCount);
|
|
757
821
|
pollLogger.error('poll_failed', { error: errorMeta(error) });
|
|
758
822
|
if (pollFailureCount === 1 || (pollFailureCount & (pollFailureCount - 1)) === 0) {
|
|
759
|
-
console.error(`${
|
|
823
|
+
console.error(`${label} polling failed; retrying in ${Math.round(retryInMs / 1000)}s.`);
|
|
760
824
|
}
|
|
761
825
|
await wait(retryInMs);
|
|
762
826
|
continue;
|
|
763
827
|
}
|
|
764
828
|
if (poll?.control?.refreshModelCatalog) {
|
|
765
|
-
|
|
766
|
-
const refreshedAvailability = await inspectAvailability(activeConfig, runtimeSelection, {
|
|
767
|
-
forceModelRefresh: true,
|
|
768
|
-
previousRuntimeProfiles: availability.runtimeProfiles,
|
|
769
|
-
});
|
|
770
|
-
availability = refreshedAvailability;
|
|
771
|
-
runtimeProfile = runtimeSelection
|
|
772
|
-
? requireAvailableRuntime(refreshedAvailability, runtimeSelection)
|
|
773
|
-
: null;
|
|
774
|
-
check = runtimeProfile
|
|
775
|
-
? null
|
|
776
|
-
: requireAvailableAgent(refreshedAvailability, agent);
|
|
777
|
-
model = runtimeProfile
|
|
778
|
-
? runtimeProfile.models.find((candidate) => candidate.id === requestedModel)?.id
|
|
779
|
-
|| runtimeProfile.models[0].id
|
|
780
|
-
: availableModel(check, requestedModel);
|
|
781
|
-
metadata = availability.metadata;
|
|
782
|
-
profiles = runtimeProfileMap(availability.runtimeProfiles);
|
|
783
|
-
await heartbeat(apiBaseUrl, {
|
|
784
|
-
deviceToken,
|
|
785
|
-
status: 'ready',
|
|
786
|
-
agent,
|
|
787
|
-
model,
|
|
788
|
-
metadata,
|
|
789
|
-
runtimeProfiles: availability.runtimeProfiles.map(publicRuntimeProfile),
|
|
790
|
-
allowInsecureHttp: flags['allow-insecure-http'],
|
|
791
|
-
});
|
|
792
|
-
pollLogger.info('model_catalog_refresh_completed', {
|
|
793
|
-
requestedAt: poll.control.refreshModelCatalog.requestedAt,
|
|
794
|
-
models: availability.runtimeProfiles.flatMap((profile) =>
|
|
795
|
-
profile.models.map((candidate) => candidate.id)),
|
|
796
|
-
});
|
|
797
|
-
} catch (error) {
|
|
798
|
-
pollLogger.warn('model_catalog_refresh_failed', { error: errorMeta(error) });
|
|
799
|
-
} finally {
|
|
800
|
-
nextCatalogRefreshAt = Date.now() + MODEL_CATALOG_REFRESH_MS;
|
|
801
|
-
}
|
|
829
|
+
void refreshModelCatalog(poll.control.refreshModelCatalog.requestedAt);
|
|
802
830
|
}
|
|
803
831
|
if (poll.run) {
|
|
804
|
-
console.log(`Claimed run ${poll.run.runId}.`);
|
|
832
|
+
console.log(`Claimed ${label} run ${poll.run.runId}.`);
|
|
805
833
|
const runtimeProfile = poll.run?.runtimeProfile || null;
|
|
806
834
|
const runAgent = normalizeAgentName(
|
|
807
835
|
poll.run?.companion?.agent
|
|
@@ -841,7 +869,7 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
|
|
|
841
869
|
continue;
|
|
842
870
|
}
|
|
843
871
|
if (once) {
|
|
844
|
-
console.log(`No pending ${
|
|
872
|
+
console.log(`No pending ${label} runs.`);
|
|
845
873
|
return;
|
|
846
874
|
}
|
|
847
875
|
}
|
|
@@ -851,6 +879,22 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
|
|
|
851
879
|
}
|
|
852
880
|
}
|
|
853
881
|
|
|
882
|
+
function pairedConfigDirs(rootDir) {
|
|
883
|
+
return productConfigDirs(rootDir).filter((dir) => readConfig(dir).deviceToken);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// One polling loop per paired product, so Dexter for Framer and Dexter for
|
|
887
|
+
// Webflow both stay online from a single bridge process.
|
|
888
|
+
async function startPairedBridges({ apiFlag, flags, rootDir }) {
|
|
889
|
+
stopRunsOnSignals();
|
|
890
|
+
const paired = pairedConfigDirs(rootDir);
|
|
891
|
+
await Promise.all((paired.length ? paired : [rootDir]).map((configDir) => {
|
|
892
|
+
const config = readConfig(configDir);
|
|
893
|
+
const apiBaseUrl = resolveApiBaseUrl({ flagValue: apiFlag, config });
|
|
894
|
+
return startCommand({ apiBaseUrl, config: { ...config, apiBaseUrl }, flags, configDir });
|
|
895
|
+
}));
|
|
896
|
+
}
|
|
897
|
+
|
|
854
898
|
function printDiagnostics(diagnostics) {
|
|
855
899
|
const marks = { info: '✓', warn: '!', error: '✗' };
|
|
856
900
|
for (const check of diagnostics.checks) {
|
|
@@ -897,31 +941,37 @@ export async function runCli(argv) {
|
|
|
897
941
|
if (parsed.args[0]) {
|
|
898
942
|
await pairCommand({ apiBaseUrl, args: parsed.args, flags: parsed.flags, configDir });
|
|
899
943
|
}
|
|
900
|
-
await
|
|
901
|
-
apiBaseUrl,
|
|
902
|
-
config: { ...readConfig(configDir), apiBaseUrl },
|
|
903
|
-
flags: parsed.flags,
|
|
904
|
-
configDir,
|
|
905
|
-
});
|
|
944
|
+
await startPairedBridges({ apiFlag: parsed.flags.api, flags: parsed.flags, rootDir: configDir });
|
|
906
945
|
return;
|
|
907
|
-
case 'pair':
|
|
908
|
-
await pairCommand({ apiBaseUrl, args: parsed.args, flags: parsed.flags, configDir });
|
|
909
|
-
console.log(`Run \`dexter-bridge start\` to bring ${
|
|
946
|
+
case 'pair': {
|
|
947
|
+
const pairedDir = await pairCommand({ apiBaseUrl, args: parsed.args, flags: parsed.flags, configDir });
|
|
948
|
+
console.log(`Run \`dexter-bridge start\` to bring ${bridgeDisplayName(readConfig(pairedDir))} online.`);
|
|
910
949
|
return;
|
|
950
|
+
}
|
|
911
951
|
case 'start':
|
|
912
|
-
await
|
|
952
|
+
await startPairedBridges({ apiFlag: parsed.flags.api, flags: parsed.flags, rootDir: configDir });
|
|
913
953
|
return;
|
|
914
|
-
case 'status':
|
|
915
|
-
|
|
954
|
+
case 'status': {
|
|
955
|
+
const paired = pairedConfigDirs(configDir);
|
|
956
|
+
if (!paired.length) requireDeviceToken(config);
|
|
957
|
+
for (const dir of paired) {
|
|
958
|
+
const dirConfig = readConfig(dir);
|
|
959
|
+
await statusCommand({
|
|
960
|
+
apiBaseUrl: resolveApiBaseUrl({ flagValue: parsed.flags.api, config: dirConfig }),
|
|
961
|
+
config: dirConfig,
|
|
962
|
+
configDir: dir,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
916
965
|
return;
|
|
966
|
+
}
|
|
917
967
|
case 'doctor':
|
|
918
968
|
await doctorCommand(requestedRuntime(parsed.flags, config));
|
|
919
969
|
return;
|
|
920
970
|
case 'logout':
|
|
921
|
-
{
|
|
922
|
-
const
|
|
923
|
-
clearConfig(
|
|
924
|
-
console.log(`${
|
|
971
|
+
for (const dir of productConfigDirs(configDir)) {
|
|
972
|
+
const dirConfig = readConfig(dir);
|
|
973
|
+
clearConfig(dir);
|
|
974
|
+
if (dirConfig.deviceToken) console.log(`${bridgeDisplayName(dirConfig)} Bridge local pairing removed.`);
|
|
925
975
|
}
|
|
926
976
|
return;
|
|
927
977
|
default:
|
package/src/config.js
CHANGED
|
@@ -23,6 +23,8 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
|
|
|
23
23
|
'./framerTools.js',
|
|
24
24
|
'./framerMcpClient.js',
|
|
25
25
|
'./framerBehaviorContract.js',
|
|
26
|
+
'./webflowTools.js',
|
|
27
|
+
'./taskEvidence.js',
|
|
26
28
|
'./outcomeWorkspace.js',
|
|
27
29
|
'./runtimeProfiles.js',
|
|
28
30
|
'./providers/index.js',
|
|
@@ -47,6 +49,7 @@ export const BRIDGE_CAPABILITIES = [
|
|
|
47
49
|
'framer-project-verification-v1',
|
|
48
50
|
'framer-interaction-verification-v4',
|
|
49
51
|
'framer-read-website-v1',
|
|
52
|
+
'webflow-site-v2',
|
|
50
53
|
'project-shell-v1',
|
|
51
54
|
'build-fingerprint-v1',
|
|
52
55
|
'tool-schema-parity-v1',
|
|
@@ -261,6 +264,23 @@ export function normalizeBridgeProduct(value) {
|
|
|
261
264
|
return BRIDGE_PRODUCTS.dexter;
|
|
262
265
|
}
|
|
263
266
|
|
|
267
|
+
export const DEXTER_HOSTS = ['framer', 'webflow'];
|
|
268
|
+
|
|
269
|
+
export function normalizeDexterHost(value) {
|
|
270
|
+
return value === 'webflow' ? 'webflow' : 'framer';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Each Dexter product keeps its own pairing so pairing one never replaces the other.
|
|
274
|
+
export function dexterHostConfigDir(rootDir, host) {
|
|
275
|
+
return normalizeDexterHost(host) === 'webflow' ? path.join(rootDir, 'webflow') : rootDir;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function bridgeDisplayName(config = {}) {
|
|
279
|
+
const product = normalizeBridgeProduct(config.product);
|
|
280
|
+
if (product.id !== 'dexter') return product.name;
|
|
281
|
+
return normalizeDexterHost(config.dexterHost) === 'webflow' ? 'Dexter for Webflow' : 'Dexter for Framer';
|
|
282
|
+
}
|
|
283
|
+
|
|
264
284
|
export function configFilePath(configDir = defaultConfigDir()) {
|
|
265
285
|
return path.join(configDir, 'config.json');
|
|
266
286
|
}
|
package/src/framerMcpClient.js
CHANGED
|
@@ -123,14 +123,14 @@ export function createFramerMcpClient({
|
|
|
123
123
|
sdkClient = client;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
async function rawCall(name, args) {
|
|
127
|
-
if (typeof callTool === 'function') return callTool(name, args);
|
|
126
|
+
async function rawCall(name, args, callTimeoutMs = timeoutMs) {
|
|
127
|
+
if (typeof callTool === 'function') return callTool(name, args, { timeoutMs: callTimeoutMs });
|
|
128
128
|
await connect();
|
|
129
129
|
try {
|
|
130
130
|
return await sdkClient.callTool(
|
|
131
131
|
{ name, arguments: args },
|
|
132
132
|
undefined,
|
|
133
|
-
{ timeout:
|
|
133
|
+
{ timeout: callTimeoutMs, resetTimeoutOnProgress: true },
|
|
134
134
|
);
|
|
135
135
|
} catch (error) {
|
|
136
136
|
if (isUnauthorized(error)) {
|
|
@@ -146,16 +146,16 @@ export function createFramerMcpClient({
|
|
|
146
146
|
|
|
147
147
|
// An expired session is refused before the program runs, so reopening the
|
|
148
148
|
// project and retrying once cannot apply a change twice.
|
|
149
|
-
async function call(name, args, retried = false) {
|
|
149
|
+
async function call(name, args, { retried = false, timeoutMs: callTimeoutMs } = {}) {
|
|
150
150
|
let result;
|
|
151
151
|
try {
|
|
152
|
-
result = unwrap(await rawCall(name, args));
|
|
152
|
+
result = unwrap(await rawCall(name, args, callTimeoutMs));
|
|
153
153
|
} catch (error) {
|
|
154
154
|
const expired = error?.code === 'FRAMER_MCP_SESSION_EXPIRED'
|
|
155
155
|
|| SESSION_EXPIRED_PATTERN.test(String(error?.message || ''));
|
|
156
156
|
if (!expired || retried || !projectId || name === 'open_project') throw error;
|
|
157
157
|
await openProject(projectId);
|
|
158
|
-
return call(name, { ...args, session }, true);
|
|
158
|
+
return call(name, { ...args, session }, { retried: true, timeoutMs: callTimeoutMs });
|
|
159
159
|
}
|
|
160
160
|
return result;
|
|
161
161
|
}
|
|
@@ -181,9 +181,9 @@ export function createFramerMcpClient({
|
|
|
181
181
|
readme: () => readme,
|
|
182
182
|
session: () => session,
|
|
183
183
|
projectId: () => projectId,
|
|
184
|
-
async exec(program) {
|
|
184
|
+
async exec(program, { timeoutMs: callTimeoutMs } = {}) {
|
|
185
185
|
requireSession();
|
|
186
|
-
return call('exec', { session, program });
|
|
186
|
+
return call('exec', { session, program }, { timeoutMs: callTimeoutMs });
|
|
187
187
|
},
|
|
188
188
|
async readDocs(paths) {
|
|
189
189
|
requireSession();
|