@gakim-digital/dexter-bridge 0.12.2 → 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/LICENSE +16 -0
- package/package.json +1 -1
- package/src/agent.js +143 -39
- 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/protocol.js +56 -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/protocol.js
CHANGED
|
@@ -536,10 +536,66 @@ export function buildFramerOutcomePrompt(outcome = {}, product, promptContext =
|
|
|
536
536
|
return { instructions, task };
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
+
export function buildWebflowOutcomePrompt(outcome = {}, product, promptContext = {}) {
|
|
540
|
+
const name = productName(product);
|
|
541
|
+
const outcomeContext =
|
|
542
|
+
outcome?.context
|
|
543
|
+
&& typeof outcome.context === 'object'
|
|
544
|
+
&& !Array.isArray(outcome.context)
|
|
545
|
+
? outcome.context
|
|
546
|
+
: {};
|
|
547
|
+
const selected = Array.isArray(outcomeContext.selectedElementIds)
|
|
548
|
+
? outcomeContext.selectedElementIds
|
|
549
|
+
: [];
|
|
550
|
+
const selectionFirst =
|
|
551
|
+
selected.length > 0
|
|
552
|
+
&& outcomeContext.contextGrant?.writeScope === 'selection';
|
|
553
|
+
const doctrine = clipText(promptContext.doctrine || '', 80_000).trim();
|
|
554
|
+
const webflowGuidance = clipText(promptContext.webflowGuidance || '', 120_000).trim();
|
|
555
|
+
const instructions = [
|
|
556
|
+
`You are the autonomous Webflow harness embedded inside ${name}.`,
|
|
557
|
+
'Work directly in the connected Webflow site through the Dexter Webflow tools. Dexter is the user interface and run controller; it does not plan or execute canvas operations for you.',
|
|
558
|
+
"Reach Webflow's own tools, listed in the Webflow tool catalog below, through webflow_call. Before using an action for the first time, call webflow_tool_help once with that tool and every action you plan to use from it.",
|
|
559
|
+
"webflow_context is the only source of the current page and selection. Pass a selected element's id ({ component, element }) verbatim to Webflow's data tools and its nodeId to webflow_snapshot. Dexter's panel replaces Webflow's MCP Bridge App: never ask the user to open a Webflow app or link.",
|
|
560
|
+
'Before your first site inspection or edit and at meaningful phase changes, write one or two natural first-person sentences of plain text that name the specific page area, element, or section and the concrete change you are about to make or are making now; optionally say what you will check next. Dexter shows this text to the user. Avoid generic updates such as "working on the next step", "thinking", "loading", or "making changes".',
|
|
561
|
+
'After your initial live inspection, call webflow_plan_task before the first mutation.',
|
|
562
|
+
'Every turn re-reads the conversation: put several actions in one webflow_call (each tool takes an actions array), batch independent reads in parallel, and build whole sections with one data_element_builder or data_whtml_builder call instead of one element per call.',
|
|
563
|
+
"Work with the site's design system: read existing classes, variables, and components first; reuse classes and link variables instead of hard-coding values; create new classes with a consistent naming scheme; never duplicate an existing class.",
|
|
564
|
+
'Webflow styles cascade desktop-first (main, then medium, small, and tiny). Set the base style, then override only what changes per breakpoint, and check smaller breakpoints.',
|
|
565
|
+
'Use native Webflow elements for function: forms, navbar, tabs, slider, lightbox, dropdown, video, Lottie, Spline, and Rive. Custom code components and Code Embed contents cannot be created from here; build the behavior natively or with GSAP interactions.',
|
|
566
|
+
'Motion: GSAP interactions through the Webflow tools, or CSS transitions on hover and focus states. Add motion only when asked.',
|
|
567
|
+
'Imagery: attachments first (webflow_upload_image), then existing site assets, then webflow_find_images followed by webflow_upload_image. Never invent image URLs. Draw geometric artwork as inline SVG.',
|
|
568
|
+
'Fonts: use fonts already on the site. If a needed Google font is missing, say so, or upload it through the Webflow fonts tool if it is available, and name the substitute.',
|
|
569
|
+
'Verification: declare the evidence in webflow_plan_task and confirm the result with a structural read after your final mutation. If a write timed out, re-read before retrying.',
|
|
570
|
+
'Do not publish. Return blocked with a reason when authorization or a user decision is missing. Return completed only after finishing and verifying.',
|
|
571
|
+
'A greeting, thanks, or question that needs no site change is not blocked: answer it in summary without reading or editing the site and return completed.',
|
|
572
|
+
'When finished, return exactly one JSON object and no markdown.',
|
|
573
|
+
'{"status":"completed|blocked|failed","summary":"one or two short, non-technical sentences for the designer","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null}',
|
|
574
|
+
doctrine
|
|
575
|
+
? `\nDexter design and execution doctrine (${String(promptContext.doctrineVersion || 'current')}):\n${doctrine}`
|
|
576
|
+
: '',
|
|
577
|
+
webflowGuidance
|
|
578
|
+
? `\nOfficial Webflow guidance:\n${webflowGuidance}`
|
|
579
|
+
: '',
|
|
580
|
+
].filter(Boolean).join('\n');
|
|
581
|
+
const task = [
|
|
582
|
+
...(selectionFirst
|
|
583
|
+
? ['This is a selection-first run. Keep every write inside the selected elements.']
|
|
584
|
+
: ['Inspect the relevant page before editing.']),
|
|
585
|
+
'',
|
|
586
|
+
`Webflow assignment:\n${JSON.stringify(outcomeAssignment(outcome))}`,
|
|
587
|
+
].join('\n');
|
|
588
|
+
return { instructions, task };
|
|
589
|
+
}
|
|
590
|
+
|
|
539
591
|
export function buildOutcomePrompt(outcome = {}, product, promptContext = {}) {
|
|
540
592
|
const name = productName(product);
|
|
541
593
|
const successStatus = outcomeSuccessStatus(outcome, product);
|
|
542
594
|
const assignment = outcomeAssignment(outcome);
|
|
595
|
+
if (outcome?.kind === 'webflow-site') {
|
|
596
|
+
const { instructions, task } = buildWebflowOutcomePrompt(outcome, product, promptContext);
|
|
597
|
+
return `${instructions}\n\n${task}`;
|
|
598
|
+
}
|
|
543
599
|
if (outcome?.kind === 'framer-project' || outcome?.context?.executionMode === 'framer-mcp') {
|
|
544
600
|
const { instructions, task } = buildFramerOutcomePrompt(outcome, product, promptContext);
|
|
545
601
|
return `${instructions}\n\n${task}`;
|