@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/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 { checkAgentAvailability, checkAllAgents, executeRun, processEnvWithCliPath } from './agent.js';
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 execute(run, executeOptions);
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(configDir, logger) {
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
- async function waitForPairing(configDir) {
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 token = readConfig(configDir).deviceToken;
300
- if (token) return token;
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 product = normalizeBridgeProduct(current.product);
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 ${product.name} Bridge pairing was disconnected. Reopen ${product.name} and run the new pairing command.`,
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: normalizeBridgeProduct(result.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
- }, configDir);
564
- const product = normalizeBridgeProduct(result.product);
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(`Status: ${result.device?.online ? 'online' : 'paired'}`);
602
- console.log(`Device: ${result.device?.name || `${product.name} Bridge`}`);
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
- deviceToken = await waitForPairing(configDir);
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(`${product.name} Bridge connected to ${apiBaseUrl}`);
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
- deviceToken = await waitForPairing(configDir);
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(configDir, pollLogger);
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(`${product.name} polling failed; retrying in ${Math.round(retryInMs / 1000)}s.`);
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
- try {
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 ${product.name} runs.`);
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 startCommand({
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 ${normalizeBridgeProduct(readConfig(configDir).product).name} online.`);
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 startCommand({ apiBaseUrl, config: { ...config, apiBaseUrl }, flags: parsed.flags, configDir });
952
+ await startPairedBridges({ apiFlag: parsed.flags.api, flags: parsed.flags, rootDir: configDir });
913
953
  return;
914
- case 'status':
915
- await statusCommand({ apiBaseUrl, config, configDir });
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 product = normalizeBridgeProduct(config.product);
923
- clearConfig(configDir);
924
- console.log(`${product.name} Bridge local pairing removed.`);
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}`;